PageSourceSearch

https://smrtbld.ai/js/offline/offline-store.js

js smrtbld.ai collected 2026-10-05 15:08:42 UTC 60,351 bytes, 1,458 lines download raw bytes

1/**
2 * Offline Store — Dexie-powered IndexedDB for offline markup data.
3 *
4 * Stores structured metadata, annotations, directory listings, sync queue,
5 * thumbnails, AND the PDF blobs themselves (inline on the markups table as
6 * of v4). The earlier design kept PDFs in the Cache API, but that path
7 * couldn't reliably serve cross-origin PDF URLs — the WebViewer offline
8 * pattern is now blob-in-Dexie → UI.loadDocument(blob).
9 *
10 * Dexie is loaded as a global (window.Dexie) via script tag in index.html.
11 *
12 * Usage:
13 *   import { offlineStore } from '../offline/offline-store.js';
14 *   await offlineStore.ready;
15 *   await offlineStore.saveMarkup(markupId, data, sizeBytes, pdfBlob);
16 *   const cached = await offlineStore.getMarkup(markupId);
17 */
18
19/* global Dexie */
20
21const DB_NAME = 'smrtbuild-offline';
22
23const db = new Dexie(DB_NAME);
24
25// v1: Original markups offline tables
26db.version(1).stores({
27    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
28    annotations: 'markupId, projectId, isDirty, lastModified',
29    directories: '[projectId+directoryId], projectId, cachedAt',
30    registers: 'projectId, cachedAt',
31    syncQueue: '++id, markupId, type, createdAt, [type+createdAt]',
32    thumbnails: 'markupId, projectId',
33});
34
35// v2: Add offlineSession for auth persistence (PIN unlock, grace period)
36db.version(2).stores({
37    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
38    annotations: 'markupId, projectId, isDirty, lastModified',
39    directories: '[projectId+directoryId], projectId, cachedAt',
40    registers: 'projectId, cachedAt',
41    syncQueue: '++id, markupId, type, createdAt, [type+createdAt]',
42    thumbnails: 'markupId, projectId',
43    offlineSession: 'key',
44});
45
46// v3: Add referenceData cache + upgrade syncQueue with priority/status/dependsOn
47db.version(3).stores({
48    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
49    annotations: 'markupId, projectId, isDirty, lastModified',
50    directories: '[projectId+directoryId], projectId, cachedAt',
51    registers: 'projectId, cachedAt',
52    syncQueue: '++id, markupId, type, priority, status, createdAt, dependsOn, [type+createdAt], [priority+createdAt]',
53    thumbnails: 'markupId, projectId',
54    offlineSession: 'key',
55    referenceData: 'key, updatedAt',
56}).upgrade(tx => {
57    // Migrate existing syncQueue entries: add default priority/status/dependsOn
58    return tx.table('syncQueue').toCollection().modify(op => {
59        if (op.priority === undefined) op.priority = 2;     // BUSINESS default
60        if (op.status === undefined) op.status = 'pending';
61        if (op.dependsOn === undefined) op.dependsOn = null;
62    });
63});
64
65// v4: PDF blobs stored directly in markups table (replaces Cache API storage).
66// No index changes — pdfBlob is an unindexed inline field.
67db.version(4).stores({
68    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
69    annotations: 'markupId, projectId, isDirty, lastModified',
70    directories: '[projectId+directoryId], projectId, cachedAt',
71    registers: 'projectId, cachedAt',
72    syncQueue: '++id, markupId, type, priority, status, createdAt, dependsOn, [type+createdAt], [priority+createdAt]',
73    thumbnails: 'markupId, projectId',
74    offlineSession: 'key',
75    referenceData: 'key, updatedAt',
76});
77
78// v5: Generic API response cache — endpoint+query keyed, per-entry TTL.
79// Distinct from `referenceData` (which is login-warmed per-key lookups).
80// apiResponses stores ad-hoc GET results so any page can read from Dexie when
81// offline without each module writing its own fallback path.
82//   key:      endpoint string (e.g., "smrtbuild/projects/123?tab=overview")
83//   pathPrefix: first path segment (for prefix-invalidation on mutations)
84//   updatedAt: timestamp for TTL checks
85//   expiresAt: absolute expiry timestamp (TTL pre-computed for fast reads)
86//   data:     the cached response body
87db.version(5).stores({
88    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
89    annotations: 'markupId, projectId, isDirty, lastModified',
90    directories: '[projectId+directoryId], projectId, cachedAt',
91    registers: 'projectId, cachedAt',
92    syncQueue: '++id, markupId, type, priority, status, createdAt, dependsOn, [type+createdAt], [priority+createdAt]',
93    thumbnails: 'markupId, projectId',
94    offlineSession: 'key',
95    referenceData: 'key, updatedAt',
96    apiResponses: 'key, pathPrefix, updatedAt, expiresAt',
97});
98
99// v6 — adds repairedPdfs for the WebViewer PDF repair cache (Phase 2-alt of
100// the WebViewer PDF Repair plan). Stores PDFNet-flattened blobs keyed by
101// markupID so later opens in this browser skip the repair round-trip.
102// Additive schema change — existing data carries forward untouched.
103db.version(6).stores({
104    markups: 'markupId, projectId, cachedAt, isPinned, lastAccessedAt, [projectId+cachedAt]',
105    annotations: 'markupId, projectId, isDirty, lastModified',
106    directories: '[projectId+directoryId], projectId, cachedAt',
107    registers: 'projectId, cachedAt',
108    syncQueue: '++id, markupId, type, priority, status, createdAt, dependsOn, [type+createdAt], [priority+createdAt]',
109    thumbnails: 'markupId, projectId',
110    offlineSession: 'key',
111    referenceData: 'key, updatedAt',
112    apiResponses: 'key, pathPrefix, updatedAt, expiresAt',
113    repairedPdfs: 'markupId, savedAt',
114});
115
116// v7 — remove stores whose primary keys must change for authenticated
117// tenant/user partitioning. Dexie cannot change an existing object store's
118// primary key in place, so this destructive half-step must land before v8
119// recreates the stores. offlineSession is deliberately retained so offline
120// sign-in can still derive and activate its exact partition.
121db.version(7).stores({
122    markups: null,
123    annotations: null,
124    directories: null,
125    registers: null,
126    syncQueue: null,
127    thumbnails: null,
128    offlineSession: 'key',
129    referenceData: null,
130    apiResponses: null,
131    repairedPdfs: null,
132});
133
134// v8 — recreate the purged stores with partitioned primary keys and atomic
135// queue leases. Rows from v6 and earlier were intentionally discarded in v7:
136// they pre-date both DatabaseName and user identity and cannot be attributed
137// safely. Fresh databases are created directly with this final schema.
138db.version(8).stores({
139    markups: '[partitionKey+markupId], partitionKey, databaseName, partitionUserId, markupId, projectId, cachedAt, isPinned, lastAccessedAt, [partitionKey+projectId], [partitionKey+cachedAt]',
140    annotations: '[partitionKey+markupId], partitionKey, databaseName, partitionUserId, markupId, projectId, isDirty, lastModified, [partitionKey+projectId]',
141    directories: '[partitionKey+projectId+directoryId], partitionKey, databaseName, partitionUserId, projectId, directoryId, cachedAt, [partitionKey+projectId]',
142    registers: '[partitionKey+projectId], partitionKey, databaseName, partitionUserId, projectId, cachedAt',
143    syncQueue: '++id, partitionKey, databaseName, partitionUserId, markupId, type, priority, status, createdAt, dependsOn, claimOwner, leaseExpiresAt, [partitionKey+status], [partitionKey+markupId], [partitionKey+type+createdAt], [partitionKey+priority+createdAt]',
144    thumbnails: '[partitionKey+markupId], partitionKey, databaseName, partitionUserId, markupId, projectId, [partitionKey+projectId]',
145    offlineSession: 'key',
146    referenceData: '[partitionKey+key], partitionKey, databaseName, partitionUserId, key, updatedAt',
147    apiResponses: '[partitionKey+key], partitionKey, databaseName, partitionUserId, key, pathPrefix, updatedAt, expiresAt, [partitionKey+pathPrefix]',
148    repairedPdfs: '[partitionKey+markupId], partitionKey, databaseName, partitionUserId, markupId, savedAt',
149});
150
151// ─── Error Handling ──────────────────────────────────────────────────
152
153db.on('versionchange', () => {
154    // Another tab opened with a newer DB version — reload to pick it up
155    console.warn('[OfflineStore] Database version changed in another tab. Reloading.');
156    window.location.reload();
157});
158
159// ─── Ready Gate ──────────────────────────────────────────────────────
160// Resolves once the DB is open. Callers can `await offlineStore.ready` and
161// inspect `offlineStore.isOpen` to know whether the DB actually came up.
162// In strict-privacy / incognito browsers IndexedDB can be blocked outright —
163// we surface that as `isOpen === false` instead of throwing on every call.
164let _isOpen = false;
165const _ready = db.open()
166    .then(() => { _isOpen = true; return true; })
167    .catch(err => {
168        console.error('[OfflineStore] Failed to open database:', {
169            name: err?.name || 'UnknownError',
170            message: err?.message || String(err),
171            innerName: err?.inner?.name || null,
172            innerMessage: err?.inner?.message || null,
173        }, err);
174        if (err.name === 'UpgradeError' || err.name === 'VersionError') {
175            console.warn('[OfflineStore] Schema conflict detected. May require a database reset.');
176        }
177        return false;
178    });
179
180/** True once the DB has opened successfully; false if IDB is blocked or broken. */
181function isOpen() {
182    return _isOpen;
183}
184
185let _activePartition = null;
186
187function activatePartition(partition) {
188    if (!partition?.key || !partition.databaseName || !partition.userId) {
189        throw new Error('Offline partition requires DatabaseName and authenticated user id.');
190    }
191    _activePartition = Object.freeze({
192        key: String(partition.key),
193        databaseName: String(partition.databaseName),
194        userId: String(partition.userId),
195    });
196    return _activePartition;
197}
198
199function deactivatePartition() {
200    _activePartition = null;
201}
202
203function getActivePartition() {
204    return _activePartition;
205}
206
207function requirePartition(expectedKey = null) {
208    const partition = _activePartition;
209    if (!partition) throw new Error('Offline partition is not active.');
210    if (expectedKey !== null && partition.key !== expectedKey) {
211        throw new DOMException('Offline partition changed.', 'AbortError');
212    }
213    return partition;
214}
215
216const partitionMarkupKey = markupId => [requirePartition().key, markupId];
217const partitionProjectKey = projectId => [requirePartition().key, projectId];
218function partitionFields() {
219    const partition = requirePartition();
220    return {
221        partitionKey: partition.key,
222        databaseName: partition.databaseName,
223        partitionUserId: partition.userId,
224    };
225}
226
227// ─── Markups (Metadata) ─────────────────────────────────────────────
228
229/**
230 * Save markup metadata for offline access.
231 * @param {number} markupId
232 * @param {object} markupDetail — the full getMarkup() response (markup, pages, layers, pageDimensions, etc.)
233 * @param {number} sizeBytes — PDF file size in bytes (for storage budget tracking)
234 */
235async function saveMarkup(markupId, markupDetail, sizeBytes, pdfBlob = null, expectedPartitionKey = null) {
236    const partitionKey = requirePartition(expectedPartitionKey).key;
237    const now = Date.now();
238    const existing = await db.markups.get([partitionKey, markupId]);
239
240    // Merge in offline-only fields that the server response won't have.
241    // _layerCache is populated during download-for-offline; the server
242    // getMarkup response doesn't include it. Same for pdfBlob.
243    const mergedData = {
244        ...markupDetail,
245        _layerCache: markupDetail._layerCache || existing?.data?._layerCache || null,
246    };
247
248    const record = {
249        ...partitionFields(),
250        markupId,
251        projectId: markupDetail.markup?.projectID || markupDetail.markup?.projectId,
252        data: mergedData,
253        sizeBytes,
254        cachedAt: existing?.cachedAt || now,
255        lastAccessedAt: now,
256        isPinned: existing?.isPinned || 0,
257        pdfBlob: pdfBlob || existing?.pdfBlob || null,
258    };
259    requirePartition(partitionKey);
260    await db.markups.put(record);
261}
262
263/** Get cached markup metadata. Returns null if not cached. */
264async function getMarkup(markupId, expectedPartitionKey = null) {
265    const partitionKey = requirePartition(expectedPartitionKey).key;
266    const key = [partitionKey, markupId];
267    const record = await db.markups.get(key);
268    if (record) {
269        // Touch lastAccessedAt for LRU tracking
270        db.markups.update(key, { lastAccessedAt: Date.now() }).catch(() => {});
271    }
272    return record || null;
273}
274
275/** Get the cached PDF blob for a markup. Returns null if not available. */
276async function getPdfBlob(markupId) {
277    const record = await db.markups.get(partitionMarkupKey(markupId));
278    return record?.pdfBlob || null;
279}
280
281// ─── Repaired PDF Cache (Phase 2-alt) ───────────────────────────────────
282//
283// Once WebViewer's renderer has crashed on a malformed PDF and our client-side
284// PDFNet repair path has produced a flattened replacement, stash it here.
285// Next open of the same drawing skips the original (known to crash the
286// renderer) and loads the flattened blob directly — no spinner, no repair.
287//
288// Independent of `markups.pdfBlob` on purpose. `markups` is the offline cache
289// for drawings pinned for offline use; a repaired PDF may need to be cached
290// even for online-only drawings that were never downloaded.
291//
292// Invalidation: 30-day TTL. If a PDF is re-uploaded server-side, the sensible
293// signal is an explicit `deleteRepairedPdfBlob(markupId)` at the save/replace
294// call site. Without a perfect cache-invalidation signal, 30 days is a
295// practical bound — worst case the user sees one extra repair spinner.
296
297const REPAIRED_PDF_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
298
299async function getRepairedPdfBlob(markupId) {
300    if (!_isOpen) return null;
301    try {
302        const key = partitionMarkupKey(markupId);
303        const record = await db.repairedPdfs.get(key);
304        if (!record) return null;
305        if (Date.now() - (record.savedAt || 0) > REPAIRED_PDF_TTL_MS) {
306            db.repairedPdfs.delete(key).catch(() => {});
307            return null;
308        }
309        return record.blob || null;
310    } catch (err) {
311        console.warn('[OfflineStore] getRepairedPdfBlob failed:', err);
312        return null;
313    }
314}
315
316async function saveRepairedPdfBlob(markupId, blob) {
317    if (!_isOpen || !blob) return false;
318    try {
319        await db.repairedPdfs.put({
320            ...partitionFields(),
321            markupId,
322            blob,
323            sizeBytes: blob.size || 0,
324            savedAt: Date.now(),
325        });
326        return true;
327    } catch (err) {
328        console.warn('[OfflineStore] saveRepairedPdfBlob failed:', err);
329        return false;
330    }
331}
332
333async function deleteRepairedPdfBlob(markupId) {
334    if (!_isOpen) return;
335    try { await db.repairedPdfs.delete(partitionMarkupKey(markupId)); }
336    catch (err) { console.warn('[OfflineStore] deleteRepairedPdfBlob failed:', err); }
337}
338
339/** Delete cached markup metadata. */
340async function deleteMarkup(markupId) {
341    await db.markups.delete(partitionMarkupKey(markupId));
342}
343
344/** Get all cached markups for a project. */
345async function getMarkupsByProject(projectId) {
346    return db.markups.where('[partitionKey+projectId]').equals(partitionProjectKey(projectId)).toArray();
347}
348
349/** Get all cached markup IDs (for quick "is this cached?" checks). */
350async function getCachedMarkupIds() {
351    const rows = await db.markups.where('partitionKey').equals(requirePartition().key).toArray();
352    return rows.map(row => row.markupId);
353}
354
355/**
356 * Get the set of projectIds that have ANY offline content cached.
357 * Used by stage-list pages to filter out projects the user can't actually
358 * open offline — showing non-openable entries just leads to "not available
359 * offline" errors when they tap in.
360 *
361 * Right now "offline content" = any markup cached. As other offline features
362 * land (documents, photos, etc.) broaden this to union their project IDs.
363 */
364async function getOfflineProjectIds() {
365    const ids = new Set();
366    await db.markups.where('partitionKey').equals(requirePartition().key).each(record => {
367        if (record.projectId != null) ids.add(record.projectId);
368    });
369    return ids;
370}
371
372/** Set pinned state (pinned markups resist auto-eviction). */
373async function setPinned(markupId, pinned) {
374    await db.markups.update(partitionMarkupKey(markupId), { isPinned: pinned ? 1 : 0 });
375}
376
377/** Get total cached size in bytes across all markups. */
378async function getTotalCachedSize() {
379    let total = 0;
380    await db.markups.where('partitionKey').equals(requirePartition().key).each(record => { total += record.sizeBytes || 0; });
381    return total;
382}
383
384/**
385 * Get eviction candidates ordered by priority (lowest priority first).
386 * Skips pinned markups.
387 */
388async function getEvictionCandidates() {
389    return db.markups
390        .where('isPinned').equals(0)
391        .and(record => record.partitionKey === requirePartition().key)
392        .sortBy('lastAccessedAt');
393}
394
395// ─── Annotations ─────────────────────────────────────────────────────
396
397function canonicalAnnotationBindings(bindings = []) {
398    const byGuid = new Map();
399    for (const binding of bindings || []) {
400        if (!binding?.guid) continue;
401        const existing = byGuid.get(binding.guid);
402        if (existing && String(existing.layerID) !== String(binding.layerID)) {
403            throw new Error('One annotation cannot be captured against multiple layers.');
404        }
405        byGuid.set(binding.guid, {
406            guid: binding.guid,
407            layerID: binding.layerID,
408            dependencyOpId: binding.dependencyOpId ?? null,
409        });
410    }
411    return [...byGuid.values()].sort((left, right) => left.guid.localeCompare(right.guid));
412}
413
414async function annotationSnapshotHash(xmlData = '', xmlDataLinks = '', annotationBindings = []) {
415    const canonicalBindings = JSON.stringify(canonicalAnnotationBindings(annotationBindings)
415);
416    const bytes = new TextEncoder().encode(
417        `${xmlData.length}:${xmlData}${xmlDataLinks.length}:${xmlDataLinks}${canonicalBindings.length}:${canonicalBindings}`);
418    const digest = await crypto.subtle.digest('SHA-256', bytes);
419    return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
420}
421
422function getAnnotationCaptureToken(record) {
423    return Object.freeze({
424        exists: Boolean(record),
425        captureVersion: Number(record?.captureVersion || 0),
426        captureOperationKey: record?.captureOperationKey || null,
427        captureHash: record?.captureHash || null,
428        lastModified: record?.lastModified ?? null,
429    });
430}
431
432function annotationTokenMatches(record, expected) {
433    if (!expected || Boolean(record) !== expected.exists) return false;
434    if (!record) return true;
435    return Number(record.captureVersion || 0) === Number(expected.captureVersion || 0)
436        && (record.captureOperationKey || null) === (expected.captureOperationKey || null)
437        && (record.captureHash || null) === (expected.captureHash || null)
438        && (record.lastModified ?? null) === (expected.lastModified ?? null);
439}
440
441/** Atomically advance and persist one dirty user-authored XFDF snapshot. */
442async function captureDirtyAnnotations(
443    markupId, projectId, xmlData, xmlDataLinks, operationKey,
444    annotationBindings = [], bindingDependencies = [], expectedPartitionKey = null,
445) {
446    if (!operationKey) throw new Error('Dirty annotation capture requires its durable operation key.');
447    const partitionKey = requirePartition(expectedPartitionKey).key;
448    const canonicalBindings = canonicalAnnotationBindings(annotationBindings);
449    const dependencies = [...new Set(bindingDependencies)].filter(Number.isInteger);
450    const captureHash = await annotationSnapshotHash(xmlData, xmlDataLinks, canonicalBindings);
451    requirePartition(partitionKey);
452    return db.transaction('rw', db.annotations, async () => {
453        requirePartition(partitionKey);
454        const key = [partitionKey, markupId];
455        const current = await db.annotations.get(key);
456        const captureVersion = Number(current?.captureVersion || 0) + 1;
457        const record = {
458            ...partitionFields(),
459            markupId,
460            projectId,
461            xmlData,
462            xmlDataLinks,
463            annotationBindings: canonicalBindings,
464            bindingDependencies: dependencies,
465            isDirty: 1,
466            baseXfdf: current?.baseXfdf ?? null,
467            captureVersion,
468            captureOperationKey: operationKey,
469            captureHash,
470            lastModified: Date.now(),
471        };
472        await db.annotations.put(record);
473        return getAnnotationCaptureToken(record);
474    });
475}
476
477/**
478 * The single clean-writer primitive. A clean result may replace local state
479 * only when the row still has the exact capture version/key/hash observed by
480 * its caller. A newer dirty capture therefore survives every stale finalizer.
481 */
482async function compareAndSaveAnnotationsClean(
483    markupId,
484    projectId,
485    xmlData,
486    xmlDataLinks,
487    baseXfdf,
488    expected,
489    annotationBindings = [],
490    bindingDependencies = [],
491    expectedPartitionKey = null,
492    { advanceVersion = false } = {},
493) {
494    const partitionKey = requirePartition(expectedPartitionKey).key;
495    const canonicalBindings = canonicalAnnotationBindings(annotationBindings);
496    const nextHash = await annotationSnapshotHash(xmlData, xmlDataLinks, canonicalBindings);
497    requirePartition(partitionKey);
498    return db.transaction('rw', db.annotations, async () => {
499        requirePartition(partitionKey);
500        const key = [partitionKey, markupId];
501        const current = await db.annotations.get(key);
502        if (!annotationTokenMatches(current, expected) || (current?.isDirty && advanceVersion)) return false;
503        const record = {
504            ...partitionFields(),
505            markupId,
506            projectId,
507            xmlData,
508            xmlDataLinks,
509            annotationBindings: canonicalBindings,
510            bindingDependencies: [...new Set(bindingDependencies)].filter(Number.isInteger),
511            isDirty: 0,
512            baseXfdf,
513            captureVersion: advanceVersion
514                ? Number(current?.captureVersion || 0) + 1
515                : Number(expected.captureVersion || 0),
516            captureOperationKey: advanceVersion ? null : expected.captureOperationKey,
517            captureHash: nextHash,
518            lastModified: Date.now(),
519        };
520        await db.annotations.put(record);
521        return true;
522    });
523}
524
525async function finalizeAnnotationsClean(
526    markupId, projectId, xmlData, xmlDataLinks, baseXfdf, expectedCapture,
527    annotationBindings = [], bindingDependencies = [], expectedPartitionKey = null,
528) {
529    return compareAndSaveAnnotationsClean(
530        markupId, projectId, xmlData, xmlDataLinks, baseXfdf, expectedCapture,
531        annotationBindings, bindingDependencies, expectedPartitionKey,
532    );
533}
534
535async function replaceAnnotationsCleanIfCurrent(markupId, projectId, xmlData, xmlDataLinks, baseXfdf, expectedCurrent) {
536    return compareAndSaveAnnotationsClean(
537        markupId, projectId, xmlData, xmlDataLinks, baseXfdf, expectedCurrent,
538        [], [], null,
539        { advanceVersion: true },
540    );
541}
542
543/** Get cached annotations for a markup. Returns null if not cached. */
544async function getAnnotations(markupId, expectedPartitionKey = null) {
545    const partitionKey = requirePartition(expectedPartitionKey).key;
546    return (await db.annotations.get([partitionKey, markupId])) || null;
547}
548
549/** Delete cached annotations. */
550async function deleteAnnotations(markupId) {
551    await db.annotations.delete(partitionMarkupKey(markupId));
552}
553
554/** Get all dirty annotations (need sync). */
555async function getDirtyAnnotations() {
556    return db.annotations.where('isDirty').equals(1).and(row => row.partitionKey === requirePartition().key).toArray();
557}
558
559// ─── Directories ─────────────────────────────────────────────────────
560
561/**
562 * Cache a directory listing response.
563 * @param {number} projectId
564 * @param {number} directoryId — 0 for root
565 * @param {object} data — the listDirectory() response
566 */
567async function saveDirectory(projectId, directoryId, data) {
568    await db.directories.put({
569        ...partitionFields(),
570        projectId,
571        directoryId,
572        data,
573        cachedAt: Date.now(),
574    });
575}
576
577/** Get cached directory listing. Returns null if not cached. */
578async function getDirectory(projectId, directoryId) {
579    return (await db.directories.get([requirePartition().key, projectId, directoryId])) || null;
580}
581
582/** Delete all cached directories for a project. */
583async function deleteDirectoriesByProject(projectId) {
584    await db.directories.where('[partitionKey+projectId]').equals(partitionProjectKey(projectId)).delete();
585}
586
587// ─── Drawing Register ────────────────────────────────────────────────
588
589/** Cache the flat drawing register response for a project. */
590async function saveRegister(projectId, data) {
591    await db.registers.put({
592        ...partitionFields(),
593        projectId,
594        data,
595        cachedAt: Date.now(),
596    });
597}
598
599/** Get cached drawing register. Returns null if not cached. */
600async function getRegister(projectId) {
601    return (await db.registers.get(partitionProjectKey(projectId))) || null;
602}
603
604/**
605 * Invalidate only mutable Markups register metadata after a selected-sheet push.
606 * PDF blobs, repaired PDFs, annotations (including dirty state), thumbnails, and
607 * unrelated projects remain byte-for-byte untouched.
608 */
609async function invalidateMarkupRegisterMetadata(projectId, markupIds = [], pageIds = []) {
610    const partitionKey = requirePartition().key;
611    const projectKey = [partitionKey, Number(projectId)];
612    const deletedPageIds = new Set(pageIds.map(Number).filter(Boolean));
613    await Promise.all([
614        db.directories.where('[partitionKey+projectId]').equals(projectKey).delete(),
615        db.registers.delete(projectKey),
616        ...[...new Set(markupIds.map(Number).filter(Boolean))].map(async markupId => {
617            const key = [partitionKey, markupId];
618            const row = await db.markups.get(key);
619            if (!row?.data) return;
620            const currentPages = Array.isArray(row.data.pages) ? row.data.pages : [];
621            const deletedPhysicalPages = new Set(currentPages
622                .filter(page => deletedPageIds.has(Number(page.pageID ?? page.pageId)))
623                .map(page => Number(page.pdfPageNumber)).filter(Boolean));
624            const pages = currentPages
625                .filter(page => !deletedPageIds.has(Number(page.pageID ?? page.pageId)))
626                .map((page, index) => ({ ...page, pageNumber: index + 1 }));
627            const oldPages = Array.isArray(row.data.oldPages)
628                ? row.data.oldPages.filter(page => !deletedPageIds.has(Number(page.pageID ?? page.pageId)))
629                : row.data.oldPages;
630            const pageDimensions = Array.isArray(row.data.pageDimensions)
631                ? row.data.pageDimensions
632                    .filter(dim => !deletedPhysicalPages.has(Number(dim.physicalPage)))
633                    .map((dim, index) => ({ ...dim, page: index + 1 }))
634                : row.data.pageDimensions;
635            await db.markups.update(key, {
636                data: { ...row.data, pages, oldPages, pageDimensions },
637                metadataInvalidatedAt: Date.now(),
638            });
639        }),
640    ]);
641}
642
643// ─── Sync Queue ──────────────────────────────────────────────────────
644
645/**
646 * Enqueue an operation for later sync.
647 * @param {string} type — 'saveAnnotation' | 'updateStatus' | 'saveLayer' | 'saveDirectory'
648 * @param {number}
648 markupId — related markup (0 for non-markup ops)
649 * @param {object} payload — operation-specific data
650 * @param {object} [opts] — { priority, dependsOn, endpoint, method }
651 *
652 * Compaction: only never-sent saveAnnotation payloads may supersede other
653 * never-sent snapshots. A payload with replayXfdf crossed the transport
654 * boundary; its exact durable key/body pair must survive a lost response and
655 * may never be merged away before the server replays that receipt.
656 */
657async function enqueueSync(type, markupId, payload, opts = {}) {
658    const partitionKey = requirePartition(opts.expectedPartitionKey).key;
659    const operationKey = opts.operationKey || _newDurableOperationKey(type);
660    const bindingDependencies = type === 'saveAnnotation'
661        ? [...new Set(opts.bindingDependencies || payload?.bindingDependencies || [])].filter(Number.isInteger)
662        : [];
663    return db.transaction('rw', db.syncQueue, async () => {
664        requirePartition(partitionKey);
665        if (type === 'saveAnnotation' && markupId && !payload?.replayXfdf) {
666            await db.syncQueue
667                .where('markupId').equals(markupId)
668                .and(op => op.partitionKey === partitionKey
669                    && op.type === 'saveAnnotation'
670                    && op.status === 'pending'
671                    && !op.payload?.replayXfdf)
672                .delete();
673        }
674
675        return db.syncQueue.add({
676            ...partitionFields(),
677            type,
678            markupId,
679            payload: type === 'saveAnnotation' ? { ...payload, bindingDependencies } : payload,
680            priority: opts.priority ?? 2,
681            status: 'pending',
682            dependsOn: opts.dependsOn ?? null,
683            bindingDependencies,
684            endpoint: opts.endpoint ?? null,
685            method: opts.method ?? 'POST',
686            operationKey,
687            createdAt: Date.now(),
688            retryCount: 0,
689        });
690    });
691}
692
693function bindingDependencyIds(op) {
694    if (op?.type !== 'saveAnnotation') return op?.dependsOn ? [op.dependsOn] : [];
695    return [...new Set([
696        op.dependsOn,
697        ...(op.bindingDependencies || []),
698        ...(op.payload?.bindingDependencies || []),
699    ].filter(dependency => Number.isInteger(dependency) && dependency > 0))];
700}
701
702function isSyncOperationReady(op, byId) {
703    return bindingDependencyIds(op).every(dependency => byId.get(dependency)?.status === 'synced');
704}
705
706async function dependentOperationIds(partitionKey, parentId) {
707    const rows = await db.syncQueue.where('partitionKey').equals(partitionKey).toArray();
708    return rows.filter(candidate => bindingDependencyIds(candidate).includes(parentId)).map(candidate => candidate.id);
709}
710
711/** Get all pending sync operations, ordered by priority then creation time. */
712async function getPendingSyncOps() {
713    const items = await db.syncQueue
714        .where('[partitionKey+status]').equals([requirePartition().key, 'pending'])
715        .toArray();
716    return items.sort((a, b) => (a.priority || 2) - (b.priority || 2) || a.createdAt - b.createdAt);
717}
718
719/** Get a single sync operation by id. */
720async function getSyncOp(id) {
721    const op = await db.syncQueue.get(id);
722    return op?.partitionKey === requirePartition().key ? op : undefined;
723}
724
725/** Delete a sync operation after successful replay. */
726async function deleteSyncOp(id) {
727    const op = await getSyncOp(id);
728    if (op) await db.syncQueue.delete(id);
729}
730
731/** Update a sync operation's status. When resetting to 'pending', retryCount
732 *  is cleared so the op gets a fresh retry budget (used by "Retry failed"). */
733async function updateSyncOpStatus(id, status, error = null) {
734    const updates = { status };
735    if (error !== null) updates.error = error;
736    if (status === 'pending') updates.retryCount = 0;
737    const op = await getSyncOp(id);
738    if (op) await db.syncQueue.update(id, updates);
739}
740
741/** Mark multiple operations as syncing. */
742async function markSyncOpsSyncing(ids) {
743    const partitionKey = requirePartition().key;
744    await db.syncQueue.where('id').anyOf(ids).and(op => op.partitionKey === partitionKey).modify({ status: 'syncing' });
745}
746
747/** Increment retry count for a failed sync op. */
748async function incrementSyncRetry(id) {
749    const op = await getSyncOp(id);
750    if (op) {
751        await db.syncQueue.update(id, { retryCount: (op.retryCount || 0) + 1 });
752    }
753}
754
755/** Get count of pending sync operations. */
756async function getPendingSyncCount() {
757    return db.syncQueue.where('[partitionKey+status]').equals([requirePartition().key, 'pending']).count();
758}
759
760/** Get all failed sync operations (for retry UI). */
761async function getFailedSyncOps() {
762    return db.syncQueue.where('[partitionKey+status]').equals([requirePartition().key, 'failed']).toArray();
763}
764
765/** Reset every failed op of the active partition to pending with a fresh retry budget; returns the count. */
766async function resetFailedSyncOps() {
767    const partitionKey = requirePartition().key;
768    // One transaction: a row a sibling tab just claimed is never reset under it (CA-546).
769    return db.transaction('rw', db.syncQueue, () => db.syncQueue
770        .where('[partitionKey+status]').equals([partitionKey, 'failed'])
771        .modify({ status: 'pending', retryCount: 0 }));
772}
773
774/** Get status counts for UI display. */
775async function getSyncStatusCounts() {
776    const all = await db.syncQueue.where('partitionKey').equals(requirePartition().key).toArray();
777    return {
778        pending: all.filter(op => op.status === 'pending').length,
779        syncing: all.filter(op => op.status === 'syncing').length,
780        failed: all.filter(op => op.status === 'failed').length,
781        conflict: all.filter(op => op.status === 'conflict').length,
782    };
783}
784
785/** Clear all synced operations (cleanup after successful sync). */
786async function clearSyncedOps() {
787    const partitionKey = requirePartition().key;
788    await db.transaction('rw', db.syncQueue, async () => {
789        const all = await db.syncQueue.where('partitionKey').equals(partitionKey).toArray();
790        const parentIds = new Set(all.flatMap(bindingDependencyIds));
791        const removable = all.filter(op => op.status === 'synced' && !parentIds.has(op.id)).map(op => op.id);
792        if (removable.length) await db.syncQueue.bulkDelete(removable);
793    });
794}
795
796/** Clear all sync ops for a markup (e.g., when removing offline data). */
797async function clearSyncOpsForMarkup(markupId) {
798    await db.syncQueue.where('[partitionKey+markupId]').equals([requirePartition().key, markupId]).delete();
799}
800
801/**
802 * Atomically claim a partition's next replay batch. Expired claims are
803 * recovered in the same transaction, so this remains safe when Web Locks is
804 * unavailable or a tab dies mid-drain.
805 */
806async function claimPendingSyncOps(owner, limit = 25, leaseMs = 30_000, expectedPartitionKey = null) {
807    const partitionKey = requirePartition(expectedPartitionKey).key;
808    const now = Date.now();
809    return db.transaction('rw', db.syncQueue, async () => {
810        await db.syncQueue
811            .where('[partitionKey+status]').equals([partitionKey, 'syncing'])
812            .and(op => !op.leaseExpiresAt || op.leaseExpiresAt <= now)
813            .modify({ status: 'pending', claimOwner: null, leaseExpiresAt: null });
814
815        const all = await db.syncQueue.where('partitionKey').equals(partitionKey).toArray();
816        const byId = new Map(all.map(op => [op.id, op]));
817        const pending = all.filter(op => op.status === 'pending' && isSyncOperationReady(op, byId));
818        const claimed = pending
819            .sort((a, b) => (a.priority || 2) - (b.priority || 2) || a.createdAt - b.createdAt)
820            .slice(0, limit);
821        const leaseExpiresAt = now + leaseMs;
822        for (const op of claimed) {
823            const operationKey = op.operationKey || _newDurableOperationKey(op.type);
824            await db.syncQueue.update(op.id, { status: 'syncing', claimOwner: owner, leaseExpiresAt, operationKey });
825            op.status = 'syncing';
826            op.claimOwner = owner;
827            op.leaseExpiresAt = leaseExpiresAt;
828            op.operationKey = operationKey;
829        }
830        return claimed;
831    });
832}
833
834const IDEMPOTENT_MARKUP_TYPES = new Set([
835    'saveAnnotation', 'updateStatus', 'saveLayer', 'saveDirectory', 'generateTransmittal',
836]);
837
838function _newDurableOperationKey(type) {
839    if (!IDEMPOTENT_MARKUP_TYPES.has(type)) return null;
840    if (typeof crypto?.randomUUID !== 'function') {
841        throw new Error('This browser cannot create the durable UUID required for offline Markups replay.');
842    }
843    return crypto.randomUUID().toLowerCase();
844}
845
846async function completeClaimedSyncOp(id, owner, expectedPartitionKey) {
847    const partitionKey = requirePartition(expectedPartitionKey).key;
848    return db.transaction('rw', db.syncQueue, async () => {
849        const op = await db.syncQueue.get(id);
850        if (!op || op.partitionKey !== partitionKey || op.claimOwner !== owner || op.status !== 'syncing') return false;
851        const dependents = await dependentOperationIds(partitionKey, id);
852        if (dependents.length > 0) {
853            await db.syncQueue.update(id, {
854                status: 'synced', claimOwner: null, leaseExpiresAt: null, error: null,
855            });
856        } else {
857            await db.syncQueue.delete(id);
858            let parentId = op.dependsOn;
859            while (parentId) {
860                const parent = await db.syncQueue.get(parentId);
861                if (!parent || parent.partitionKey !== partitionKey || parent.status !== 'synced') break;
862                const remainingChildren = await dependentOperationIds(partitionKey, parentId);
863                if (remainingChildren.length > 0) break;
864                await db.syncQueue.delete(parentId);
865                parentId = parent.dependsOn;
866            }
867        }
868        return true;
869    });
870}
871
872async function incrementClaimedSyncRetry(id, owner, expectedPartitionKey) {
873    const partitionKey = requirePartition(expectedPartitionKey).key;
874    return db.transaction('rw', db.syncQueue, async () => {
875        const op = await db.syncQueue.get(id);
876        if (!op || op.partitionKey !== partitionKey || op.claimOwner !== owner || op.status !== 'syncing') return null;
877        const retryCount = (op.retryCount || 0) + 1;
878        await db.syncQueue.update(id, { retryCount });
879        return retryCount;
880    });
881}
882
883async function renewClaimedSyncOp(id, owner, expectedPartitionKey, leaseMs = 30_000) {
884    const partitionKey = requirePartition(expectedPartitionKey).key;
885    return db.transaction('rw', db.syncQueue, async () => {
886        const op = await db.syncQueue.get(id);
887        if (!op || op.partitionKey !== partitionKey || op.claimOwner !== owner || op.status !== 'syncing') return false;
888        await db.syncQueue.update(id, { leaseExpiresAt: Date.now() + leaseMs });
889        return true;
890    });
891}
892
893async function updateClaimedSyncPayload(id, owner, expectedPartitionKey, payload) {
894    const partitionKey = requirePartition(expectedPartitionKey).key;
895    return db.transaction('rw', db.syncQueue, async () => {
896        const op = await db.syncQueue.get(id);
897        if (!op || op.partitionKey !== partitionKey || op.claimOwner !== owner || op.status !== 'syncing') return false;
898        await db.syncQueue.update(id, { payload });
899        return true;
900    });
901}
902
903async function releaseClaimedSyncOp(id, owner, expectedPartitionKey, status = 'pending', error = null) {
904    const partitionKey = requirePartition(expectedPartitionKey).key;
905    return db.transaction('rw', db.syncQueue, async () => {
906        const op = await db.syncQueue.get(id);
907        if (!op || op.partitionKey !== partitionKey || op.claimOwner !== owner || op.status !== 'syncing') return false;
908        const updates = { status, claimOwner: null, leaseExpiresAt: null };
909        if (error !== null) updates.error = error;
910        if (status === 'pending') updates.retryCount = op.retryCount || 0;
911        await db.syncQueue.update(id, updates);
912        return true;
913    });
914}
915
916async function releaseOwnedSyncClaims(owner, expectedPartitionKey) {
917    const partitionKey = requirePartition(expectedPartitionKey).key;
918    return db.syncQueue
919        .where('[partitionKey+status]').equals([partitionKey, 'syncing'])
920        .and(op => op.claimOwner === owner)
921        .modify({ status: 'pending', claimOwner: null, leaseExpiresAt: null });
922}
923
924// ─── Layer Temp-ID Reconciliation ────────────────────────────────────
925//
926// When a layer is created offline, the client assigns a temp string ID
927// (`tmp-{uuid}`). On sync, the server returns the real numeric LayerID.
928// We then need to rewrite every reference to the temp ID across:
929//   1. markups.data._layerCache[pageID].layers[*].layerID
930//   2. markups.data._layerCache[pageID].layerGroups[*].layers[*].layerID
931//   3. markups.data._layerCache[pageID].layers[*].annotations[*].layerID
932//   4. Pending syncQueue rows whose payload body references the temp ID
933//      (e.g., a queued edit/delete of the just-created layer, or saveAnnotation
934//      ops that included the tempId somewhere).
935
936/**
937 * Rewrite every occurrence of a temp layer ID to the server-assigned real ID
938 * in the Dexie cache for this markup AND in any pending sync ops.
939 *
940 * Returns { cacheRewrites, queueRewrites } for logging/telemetry.
941 *
942 * @param {number} markupId
943 * @param {string|number} tempLayerId — the `tmp-xxx` placeholder
944 * @param {number|string} realLayerId — the server's layerID
945 */
946async function reconcileLayerIds(markupId, tempLayerId, realLayerId, expectedPartitionKey = null) {
947    if (tempLayerId == null || realLayerId == null) return { cacheRewrites: 0, queueRewrites: 0 };
948
949    const tempStr = String(tempLayerId);
950    const realVal = realLayerId;
951    let cacheRewrites = 0;
952    let queueRewrites = 0;
953
954    const partitionKey = requirePartition(expectedPartitionKey).key;
955    const markupKey = [partitionKey, markupId];
956    let annotationRewrites = 0;
957
958    return db.transaction('rw', db.markups, db.annotations, db.syncQueue, async () => {
959        requirePartition(partitionKey);
960        const record = await db.markups.get(markupKey);
961        if (record?.data?._layerCache) {
962            const nextData = structuredClone(record.data);
963            if (_rewriteLayerIdInObject(nextData._layerCache, tempStr, realVal)) cacheRewrites++;
964            if (cacheRewrites) await db.markups.update(markupKey, { data: nextData });
965        }
966
967        const annotation = await db.annotations.get(markupKey);
968        let nextCaptureToken = null;
969        if (annotation) {
970            const nextBindings = canonicalAnnotationBindings(annotation.annotationBindings || [])
971                .map(binding => String(binding.layerID) === tempStr
972                    ? { ...binding, layerID: realVal }
973                    : binding);
974            if (nextBindings.some((binding, index) => String(binding.layerID) !== String((annotation.annotationBindings || [])[index]?.layerID))) {
975                const captureHash = await Dexie.waitFor(annotationSnapshotHash(
976                    annotation.xmlData, annotation.xmlDataLinks, nextBindings));
977                const updates = { annotationBindings: nextBindings, captureHash };
978                await db.annotations.update(markupKey, updates);
979                const nextAnnotation = { ...annotation, ...updates };
980                nextCaptureToken = getAnnotationCaptureToken(nextAnnotation);
981                annotationRewrites++;
982            }
983        }
984
985        const candidates = await db.syncQueue.where('partitionKey').equals(partitionKey)
986            .and(op => Number(op.markupId) === Number(markupId)
987                && ['pending', 'failed'].includes(op.status)
988                && !op.payload?.replayXfdf)
989            .toArray();
990        for (const op of candidates) {
991            const payload = structuredClone(op.payload || {});
992            if (!_rewriteLayerIdInObject(payload, tempStr, realVal)) continue;
993            if (nextCaptureToken && annotationTokenMatches(annotation, op.payload?.captureToken)) {
994                payload.captureToken = nextCaptureToken;
995            }
996            await db.syncQueue.update(op.id, { payload });
997            queueRewrites++;
998        }
999
1000        return { cacheRewrites, annotationRewrites, queueRewrites };
1001    });
1002}
1003
1004/**
1005 * After an offline-created record (e.g. an RFI draft) syncs and the server returns
1006 * its real id, stamp that id into every DEPENDENT op still waiting on it — the
1007 * queued file uploads carry `dependsOn === createOpId` and a `payload.targetId`
1008 * slot that starts null. Returns the number of dependent ops updated.
1009 *
1010 * Generic sibling to reconcileLayerIds: the create op's own id is the dependency
1011 * key, so no temp-id string matching is needed — we just fill the resolved id in.
1012 */
1013async function reconcileCreatedTaskId(createOpId, realId) {
1014    if (createOpId == null || realId == null) return 0;
1015    const deps = await db.syncQueue
1016        .where('status').anyOf(['pending', 'failed'])
1017        .and(op => op.partitionKey === requirePartition().key && op.dependsOn === createOpId)
1018        .toArray();
1019    let n = 0;
1020    for (const op of deps) {
1021        const payload = { ...(op.payload || {}), targetId: realId };
1022        await db.syncQueue.update(op.id, { payload });
1023        n++;
1024    }
1025    return n;
1026}
1027
1028/** Walk an object tree and swap any `layerID`/`LayerID` values that match tempStr. Returns true if anything changed. */
1029function _rewriteLayerIdInObject(obj, tempStr, realVal) {
1030    if (obj === null || typeof obj !== 'object') return false;
1031    let changed = false;
1032
1033    if (Array.isArray(obj)) {
1034        for (const item of obj) {
1035            if (_rewriteLayerIdInObject(item, tempStr, realVal)) changed = true;
1036        }
1037        return changed;
1038    }
1039
1040    for (const key of Object.keys(obj)) {
1041        const v = obj[key];
1042        if ((key === 'layerID' || key === 'LayerID') && String(v) === tempStr) {
1043            obj[key] = realVal;
1044            changed = true;
1045        } else if (v && typeof v === 'object') {
1046            if (_rewriteLayerIdInObject(v, tempStr, realVal)) changed = true;
1047        }
1048    }
1049    return changed;
1050}
1051
1052// ─── Thumbnails ──────────────────────────────────────────────────────
1053
1054/**
1055 * Save a low-res thumbnail for register view.
1056 * @param {number} markupId
1057 * @param {number} projectId
1058 * @param {Blob} blob — thumbnail image blob (JPEG or PNG)
1059 */
1060async function saveThumbnail(markupId, projectId, blob) {
1061    await db.thumbnails.put({ ...partitionFields(), markupId, projectId, blob, cachedAt: Date.now() });
1062}
1063
1064/** Get cached thumbnail blob. Returns null if not cached. */
1065async function getThumbnail(markupId) {
1066    const record = await db.thumbnails.get(partitionMarkupKey(markupId));
1067    return record?.blob || null;
1068}
1069
1070/** Delete thumbnail. */
1071async function deleteThumbnail(markupId) {
1072    await db.thumbnails.delete(partitionMarkupKey(markupId));
1073}
1074
1075// ─── Reference Data Cache ───────────────────────────────────────────
1076
1077/**
1078 * Store reference data in IndexedDB by key.
1079 * @param {string} key — cache key (e.g., 'projects', 'userProfile')
1080 * @param {any} data — the data to cache
1081 */
1082async function setReferenceData(key, data) {
1083    await db.referenceData.put({ ...partitionFields(), key, data, updatedAt: Date.now() });
1084}
1085
1086/**
1087 * Get cached reference data by key. Returns null if not cached.
1088 * @param {string} key
1089 * @returns {Promise<any|null>}
1090 */
1091async function getReferenceData(key) {
1092    const entry = await db.referenceData.get([requirePartition().key, key]);
1093    return entry?.data ?? null;
1094}
1095
1096/**
1097 * Get reference data with staleness metadata.
1098 * @param {string} key
1099 * @returns {Promise<{ data: any, updatedAt: number }|null>}
1100 */
1101async function getReferenceDataWithMeta(key) {
1102    const entry = await db.referenceData.get([requirePartition().key, key]);
1103    if (!entry) return null;
1104    return { data: entry.data, updatedAt: entry.updatedAt };
1105}
1106
1107/** Clear all reference data. */
1108async function clearReferenceData() {
1109    await db.referenceData.where('partitionKey').equals(requirePartition().key).delete();
1110}
1111
1112// ─── Generic API Response Cache (v5) ────────────────────────────────
1113// Shared-by-default cache for GET responses that aren't covered by
1114// referenceData (which is login-warmed per-key). The interceptor reads/writes
1115// these; pages don't need to touch the table directly.
1116
1117/**
1118 * Store an API response.
1119 * @param {string} key — endpoint+query string (e.g., "smrtbuild/projects?page=1")
1120 * @param {any} data — parsed response body
1121 * @param {number} ttlMs — TTL from now in ms
1122 */
1123async function setApiResponse(key, data, ttlMs) {
1124    const now = Date.now();
1125    const pathPrefix = _pathPrefixFromKey(key);
1126    await db.apiResponses.put({
1127        ...partitionFields(),
1128        key,
1129        pathPrefix,
1130        data,
1131        updatedAt: now,
1132        expiresAt: now + (ttlMs || 0),
1133    });
1134}
1135
1136/**
1137 * Get an API response by key. Returns { data, updatedAt, expiresAt, isStale }
1138 * or null if not cached. Stale entries ARE returned — offline-first philosophy
1139 * means a stale response beats a blank screen. Callers can check isStale.
1140 */
1141async function getApiResponse(key) {
1142    const entry = await db.apiResponses.get([requirePartition().key, key]);
1143    if (!entry) return null;
1144    return {
1145        data: entry.data,
1146        updatedAt: entry.updatedAt,
1147        expiresAt: entry.expiresAt,
1148        isStale: Date.now() > entry.expiresAt,
1149    };
1150}
1151
1152/**
1153 * Invalidate all cached GET responses whose path-prefix matches.
1154 * Called after a successful mutation so related reads don't serve stale data.
1155 * Example: a PUT to "smrtbuild/projects/123" invalidates any GET whose key starts with "smrtbuild/projects".
1156 */
1157async function invalidateApiResponsesByPrefix(pathPrefix) {
1158    if (!pathPrefix) return 0;
1159    return db.apiResponses.where('[partitionKey+pathPrefix]').equals([requirePartition().key, pathPrefix]).delete();
1160}
1161
1162/**
1163 * Sweep out expired entries. Called opportunistically (e.g., from a cache
1164 * warm pass) — stale-served-offline means expiry isn't always a reason to
1165 * delete, but it IS a reason to bound the table.
1166 */
1167async function pruneExpiredApiResponses(olderThanMs = 7 * 24 * 60 * 60 * 1000) {
1168    const cutoff = Date.now() - olderThanMs;
1169    return db.apiResponses.where('expiresAt').below(cutoff).and(row => row.partitionKey === requirePartition().key).delete();
1170}
1171
1172/** Clear the entire API response cache (used on logout). */
1173async function clearApiResponses() {
1174    await db.apiResponses.where('partitionKey').equals(requirePartition().key).delete();
1175}
1176
1177/**
1178 * Check if any apiResponses entry's key matches a predicate. Used by the tab
1179 * filter to decide "is this feature cached for this project?" without enumerating
1180 * every possible endpoint shape (path-based vs query-based, etc.).
1181 * Short-circuits on first match.
1182 */
1183async function hasApiResponseMatching(predicate) {
1184    let found = false;
1185    await db.apiResponses.where('partitionKey').equals(requirePartition().key).each(entry => {
1186        if (!found && predicate(entry.key)) found = true;
1187    });
1188    return found;
1189}
1190
1191function _pathPrefixFromKey(key) {
1192    // key looks like "smrtbuild/projects/123?tab=overview"
1193    // prefix = "smrtbuild/projects" (first two segments)
1194    const pathOnly = key.split('?')[0];
1195    const segments = pathOnly.split('/').filter(Boolean);
1196    return segments.slice(0, 2).join('/');
1197}
1198
1199// ─── Offline Session (Auth Persistence) ──────────────────────────────
1200
1201const SESSION_KEY = 'session';
1202
1203/**
1204 * Cache the auth session in IndexedDB for offline access.
1205 * Called by auth.setAuth() after every successful login.
1206 */
1207async function cacheSession(token, user, company) {
1208    const partition = requirePartition();
1209    const existing = await db.offlineSession.get(SESSION_KEY) || {};
1210    await db.offlineSession.put({
1211        ...existing,
1212        key: SESSION_KEY,
1213        token,
1214        user,
1215        company,
1216        partitionKey: partition.key,
1217        databaseName: partition.databaseName,
1218        partitionUserId: partition.userId,
1219        lastOnlineAuth: Date.now(),
1220    });
1221}
1222
1223/** Get the cached session. Returns null if none. */
1224async function getSession() {
1225    return (await db.offlineSession.get(SESSION_KEY)) || null;
1226}
1227
1228/** Clear the cached session (called on explicit logout). */
1229async function clearSessionData() {
1230    await db.offlineSession.delete(SESSION_KEY);
1231}
1232
1233/** Update the lastOnlineAuth timestamp (keeps grace period sliding). */
1234async function touchOnlineAuth() {
1235    const session = await db.offlineSession.get(SESSION_KEY);
1236    if (session) {
1237        await db.offlineSession.update(SESSION_KEY, { lastOnlineAuth: Date.now() });
1238    }
1239}
1240
1241/** Set the offline session expiry (after PIN unlock). */
1242async function setOfflineSessionExpiry(expiryTimestamp) {
1243    const session = await db.offlineSession.get(SESSION_KEY);
1244    if (session) {
1245        await db.offlineSession.update(SESSION_KEY, { offlineSessionExpiry: expiryTimestamp });
1246    }
1247}
1248
1249/** Get the offline session expiry. */
1250async function getOfflineSessionExpiry() {
1251    const session = await db.offlineSession.get(SESSION_KEY);
1252    return session?.offlineSessionExpiry || null;
1253}
1254
1255/** Update PIN hash and salt in the session. */
1256async function updateSessionPin(pinHash, pinSalt) {
1257    const session = await db.offlineSession.get(SESSION_KEY);
1258    if (session) {
1259        await db.offlineSession.update(SESSION_KEY, { pinHash, pinSalt });
1260    }
1261}
1262
1263// ─── Bulk Cleanup ────────────────────────────────────────────────────
1264
1265/**
1266 * Remove ALL offline data for a single markup — including the PDF blob,
1267 * since that now lives inline on the markups row (v4+).
1268 */
1269async function purgeMarkup(markupId) {
1270    const key = partitionMarkupKey(markupId);
1271    await Promise.all([
1272        db.markups.delete(key),
1273        db.annotations.delete(key),
1274        db.thumbnails.delete(key),
1275        db.repairedPdfs.delete(key),
1276        clearSyncOpsForMarkup(markupId),
1277    ]);
1278}
1279
1280/**
1281 * Remove ALL offline data for a project (all markups + directories).
1282 * PDF blobs are removed alongside metadata since they're inline (v4+).
1283 */
1284async function purgeProject(projectId) {
1285    const partitionKey = requirePartition().key;
1286    const projectKey = [partitionKey, projectId];
1287    const markupIds = await db.markups
1288        .where('[partitionKey+projectId]').equals(projectKey)
1289        .toArray()
1290        .then(rows => rows.map(row => row.markupId));
1291
1292    await Promise.all([
1293        db.markups.where('[partitionKey+projectId]').equals(projectKey).delete(),
1294        db.annotations.where('[partitionKey+projectId]').equals(projectKey).delete(),
1295        db.thumbnails.where('[partitionKey+projectId]').equals(projectKey).delete(),
1296        db.directories.where('[partitionKey+projectId]').equals(projectKey).delete(),
1297        db.registers.delete(projectKey),
1298        // Clear sync ops for all markups in this project
1299        ...markupIds.map(id => db.syncQueue.where('[partitionKey+markupId]').equals([partitionKey, id]).delete()),
1300        ...markupIds.map(id => db.repairedPdfs.delete([partitionKey, id])),
1301    ]);
1302
1303    return markupIds;
1304}
1305
1306/**
1307 * Nuke all cached markup data. Returns the list of markup IDs that had
1308 * cached PDFs for any post-cleanup logging.
1309 *
1310 * Intentionally preserves `offlineSession` (PIN + grace window) and
1311 * `referenceData` — the user's auth and reference caches shouldn't be
1312 * lost when they're just clearing offline drawings.
1313 */
1314async function purgeAll() {
1315    const partitionKey = requirePartition().key;
1316    const markupIds = await getCachedMarkupIds();
1317    await Promise.all([
1318        db.markups.where('partitionKey').equals(partitionKey).delete(),
1319        db.annotations.where('partitionKey').equals(partitionKey).delete(),
1320        db.thumbnails.where('partitionKey').equals(partitionKey).delete(),
1321        db.directories.where('partitionKey').equals(partitionKey).delete(),
1322        db.registers.where('partitionKey').equals(partitionKey).delete(),
1323        db.syncQueue.where('partitionKey').equals(partitionKey).delete(),
1324        db.repairedPdfs.where('partitionKey').equals(partitionKey).delete(),
1325    ]);
1326    return markupIds;
1327}
1328
1329// ─── Stats ───────────────────────────────────────────────────────────
1330
1331/** Get storage usage breakdown by project. */
1332async function getUsageByProject() {
1333    const projects = new Map();
1334    await db.markups.where('partitionKey').equals(requirePartition().key).each(record => {
1335        const pid = record.projectId;
1336        if (!projects.has(pid)) {
1337            projects.set(pid, { projectId: pid, markupCount: 0, totalBytes: 0, markups: [] });
1338        }
1339        const p = projects.get(pid);
1340        p.markupCount++;
1341        p.totalBytes += record.sizeBytes || 0;
1342        p.markups.push({
1343            markupId: record.markupId,
1344            sizeBytes: record.sizeBytes,
1345            cachedAt: record.cachedAt,
1346            lastAccessedAt: record.lastAccessedAt,
1347            isPinned: record.isPinned,
1348        });
1349    });
1350    return [...projects.values()];
1351}
1352
1353// ─── Export ──────────────────────────────────────────────────────────
1354
1355export const offlineStore = {
1356    ready: _ready,
1357    isOpen,
1358    activatePartition,
1359    deactivatePartition,
1360    getActivePartition,
1361    requirePartition,
1362
1363    // Markups
1364    saveMarkup,
1365    getMarkup,
1366    getPdfBlob,
1367    deleteMarkup,
1368    getMarkupsByProject,
1369    getCachedMarkupIds,
1370    getOfflineProjectIds,
1371    setPinned,
1372    getTotalCachedSize,
1373    getEvictionCandidates,
1374
1375    // Repaired PDF cache (WebViewer PDF repair, Phase 2-alt)
1376    getRepairedPdfBlob,
1377    saveRepairedPdfBlob,
1378    deleteRepairedPdfBlob,
1379
1380    // Annotations
1381    captureDirtyAnnotations,
1382    finalizeAnnotationsClean,
1383    replaceAnnotationsCleanIfCurrent,
1384    getAnnotationCaptureToken,
1385    getAnnotations,
1386    deleteAnnotations,
1387    getDirtyAnnotations,
1388
1389    // Directories
1390    saveDirectory,
1391    getDirectory,
1392    deleteDirectoriesByProject,
1393
1394    // Register
1395    saveRegister,
1396    getRegister,
1397    invalidateMarkupRegisterMetadata,
1398
1399    // Sync Queue
1400    enqueueSync,
1401    getPendingSyncOps,
1402    getSyncOp,
1403    deleteSyncOp,
1404    updateSyncOpStatus,
1405    markSyncOpsSyncing,
1406    incrementSyncRetry,
1407    getPendingSyncCount,
1408    getFailedSyncOps,
1409    resetFailedSyncOps,
1410    getSyncStatusCounts,
1411    clearSyncedOps,
1412    clearSyncOpsForMarkup,
1413    claimPendingSyncOps,
1414    completeClaimedSyncOp,
1415    incrementClaimedSyncRetry,
1416    renewClaimedSyncOp,
1417    updateClaimedSyncPayload,
1418    releaseClaimedSyncOp,
1419    releaseOwnedSyncClaims,
1420    reconcileLayerIds,
1421    reconcileCreatedTaskId,
1422
1423    // Reference Data
1424    setReferenceData,
1425    getReferenceData,
1426    getReferenceDataWithMeta,
1427    clearReferenceData,
1428
1429    // Generic API Response Cache (v5)
1430    setApiResponse,
1431    getApiResponse,
1432    invalidateApiResponsesByPrefix,
1433    pruneExpiredApiResponses,
1434    clearApiResponses,
1435    hasApiResponseMatching,
1436
1437    // Thumbnails
1438    saveThumbnail,
1439    getThumbnail,
1440    deleteThumbnail,
1441
1442    // Offline Session (Auth)
1443    cacheSession,
1444    getSession,
1445    clearSessionData,
1446    touchOnlineAuth,
1447    setOfflineSessionExpiry,
1448    getOfflineSessionExpiry,
1449    updateSessionPin,
1450
1451    // Bulk
1452    purgeMarkup,
1453    purgeProject,
1454    purgeAll,
1455
1456    // Stats
1457    getUsageByProject,
1458};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.