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.