1import { DEFAULT_APP_SETTINGS, WORKSPACE_SCHEMA_VERSION } from '../invoice-schema/schema.d3b160539c77.js'; 2import { assertMonotonicLedgerReplacement, validateNumberingLedger } from '../invoice-numbering/regulated-numbering-ledger.470ff3a0cddf.js'; 3import { regulatedNumberingLockName, regulatedNumberingStorageKey } from '../invoice-numbering/regulated-numbering-repository.255322b68ce5.js'; 4import { rebuildDocumentProjection } from './document-projection.299c10ef47eb.js'; 5import { 6 createWorkspaceV1Backup, 7 validateWorkspaceV1Backup 8} from './workspace-backup-v1.56f7a7814d81.js'; 9 10const LEGACY_NUMBERING_MARKET_ID = 'norway'; 11 12function valuesForPrefix(entries, prefix) { 13 return entries 14 .filter(([key, value]) => key.startsWith(prefix) && value) 15 .map(([, value]) => value); 16} 17 18function assetRecord(id, dataUrl) { 19 return { 20 id, 21 mediaType: String(dataUrl).slice(5, String(dataUrl).indexOf(';')) || 'image/*', 22 dataUrl 23 }; 24} 25 26function readFoundation(entries) { 27 const values = new Map(entries); 28 const root = values.get('workspace'); 29 if (!root?.workspaceId || !root?.activeBusinessId) return null; 30 const workspace = values.get(`workspace:${root.workspaceId}`); 31 const business = values.get(`business:${root.activeBusinessId}`); 32 if (!workspace || !business) return null; 33 return { root, workspace, business, values }; 34} 35 36function ledgerMarketId(ledger) { 37 return String(ledger?.marketId || LEGACY_NUMBERING_MARKET_ID); 38} 39 40function ledgerIdentity(ledger) { 41 return `${ledgerMarketId(ledger)}:${ledger.businessId}`; 42} 43 44function defaultLockProvider() { 45 return globalThis.navigator?.locks || null; 46} 47 48function requireLockProvider(lockProvider) { 49 if (!lockProvider || typeof lockProvider.request !== 'function') { 50 throw new Error('Workspace restore of regulated numbering requires an exclusive browser lock.'); 51 } 52} 53 54async function currentLedgerScopes(storage) { 55 const entries = await storage.entries('numbering-ledger:'); 56 return entries 57 .filter(([, value]) => value) 58 .map(([, value]) => validateNumberingLedger(value)) 59 .map((ledger) => ({ marketId: ledgerMarketId(ledger), businessId: ledger.businessId })); 60} 61 62function backupLedgerScopes(backup) { 63 return backup.numberingLedgers.map((ledger) => ({ marketId: ledgerMarketId(ledger), businessId: ledger.businessId })); 64} 65 66function unionScopes(...scopeLists) { 67 const byIdentity = new Map(); 68 scopeLists.flat().forEach((scope) => byIdentity.set(`${scope.marketId}:${scope.businessId}`, scope)); 69 return [...byIdentity.values()].sort((a, b) => `${a.marketId}:${a.businessId}`.localeCompare(`${b.marketId}:${b.businessId}`)); 70} 71 72// Restore must serialize against every normal regulated-numbering write (commitWritesWithRegulatedNumbering) 73// touching the same business+market, otherwise a concurrent tab could reserve a number between restore's 74// read of currentEntries and its destructive commit â a number the restored ledger would then silently erase. 75async function withRestoreLocks(lockProvider, scopes, index, task) { 76 if (index >= scopes.length) return task(); 77 const { businessId, marketId } = scopes[index]; 78 return lockProvider.request( 79 regulatedNumberingLockName(businessId, { marketId }), 80 { mode: 'exclusive' }, 81 () => withRestoreLocks(lockProvider, scopes, index + 1, task) 82 ); 83} 84 85export async function collectWorkspaceV1Backup(storage, { exportedAt = new Date().toISOString() } = {}) { 86 if (!storage || typeof storage.snapshot !== 'function') { 87 throw new Error('Atomic workspace snapshot storage is required.'); 88 } 89 90 const snapshotEntries = await storage.snapshot(); 91 const foundation = readFoundation(snapshotEntries); 92 if (!foundation) throw new Error('Workspace foundation is unavailable.'); 93 94 const businesses = valuesForPrefix(snapshotEntries, 'business:'); 95 const clients = valuesForPrefix(snapshotEntries, 'client:'); 96 const items = valuesForPrefix(snapshotEntries, 'item:'); 97 const documents = valuesForPrefix(snapshotEntries, 'invoice:'); 98 const payments = valuesForPrefix(snapshotEntries, 'payment:'); 99 const attachments = valuesForPrefix(snapshotEntries, 'attachment:'); 100 const numberingLedgers = valuesForPrefix(snapshotEntries, 'numbering-ledger:'); 101 const assets = snapshotEntries 102 .filter(([key, dataUrl]) => key.startsWith('logo:') && typeof dataUrl === 'string' && dataUrl.startsWith('data:image/')) 103 .map(([id, dataUrl]) => assetRecord(id, dataUrl)); 104 const preferences = foundation.values.get('settings') || DEFAULT_APP_SETTINGS; 105
106 return createWorkspaceV1Backup({ 107 workspace: foundation.workspace, 108 businesses, 109 clients, 110 items, 111 documents, 112 payments, 113 attachments, 114 assets, 115 numberingLedgers, 116 preferences, 117 exportedAt 118 }); 119} 120 121function validateNumberingRestore(currentEntries, backup) { 122 const currentLedgers = currentEntries 123 .filter(([key, value]) => key.startsWith('numbering-ledger:') && value) 124 .map(([, value]) => validateNumberingLedger(value)); 125 const incomingByScope = new Map(backup.numberingLedgers.map((ledger) => [ledgerIdentity(ledger), ledger])); 126 127 currentLedgers.forEach((current) => { 128 const incoming = incomingByScope.get(ledgerIdentity(current)); 129 if (!incoming) { 130 throw new Error(`Backup restore would remove regulated numbering history for ${ledgerMarketId(current)} business ${current.businessId}.`); 131 } 132 assertMonotonicLedgerReplacement(current, incoming); 133 }); 134} 135 136export async function restoreWorkspaceV1Backup(storage, payload, { lockProvider = defaultLockProvider() } = {}) { 137 if (!storage || typeof storage.entries !== 'function' || (typeof storage.commitExact !== 'function' && typeof storage.commit !== 'function')) { 138 throw new Error('Transactional enumerable workspace storage is required.'); 139 } 140 const backup = await validateWorkspaceV1Backup(payload); 141 142 // A cheap prefix-only read decides which locks to acquire; it never drives validation or 143 // commit decisions itself, so it does not need to happen inside the lock. 144 const scopes = unionScopes(await currentLedgerScopes(storage), backupLedgerScopes(backup)); 145 if (scopes.length) requireLockProvider(lockProvider); 146 147 return withRestoreLocks(lockProvider, scopes, 0, () => performRestore(storage, backup)); 148} 149 150async function performRestore(storage, backup) { 151 const currentEntries = await storage.entries(); 152 validateNumberingRestore(currentEntries, backup); 153 const writes = currentEntries.map(([key]) => [key, undefined]); 154 155 const root = { 156 schemaVersion: WORKSPACE_SCHEMA_VERSION, 157 workspaceId: backup.workspace.id, 158 activeBusinessId: backup.workspace.activeBusinessId 159 }; 160 const settings = { 161 ...DEFAULT_APP_SETTINGS, 162 ...backup.preferences, 163 workspaceVersion: WORKSPACE_SCHEMA_VERSION, 164 workspaceId: backup.workspace.id, 165 activeBusinessId: backup.workspace.activeBusinessId, 166 lastKnownInvoiceCount: backup.documents.filter((document) => !document.deletedAt).length 167 }; 168 const activeDocuments = backup.documents.filter((document) => !document.deletedAt); 169 const newestDocument = [...activeDocuments] 170 .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0] || null; 171 172 writes.push( 173 ['workspace', root], 174 [`workspace:${backup.workspace.id}`, backup.workspace], 175 ['settings', settings], 176 ...backup.businesses.map((business) => [`business:${business.id}`, business]), 177 ...backup.clients.map((client) => [`client:${client.id}`, client]), 178 ...backup.items.map((item) => [`item:${item.id}`, item]), 179 ...backup.documents.map((document) => [`invoice:${document.id}`, document]), 180 ...backup.payments.map((payment) => [`payment:${payment.id}`, payment]), 181 ...backup.attachments.map((attachment) => [`attachment:${attachment.id}`, attachment]), 182 ...backup.assets.map((asset) => [asset.id, asset.dataUrl]), 183 ...backup.numberingLedgers.map((ledger) => [regulatedNumberingStorageKey(ledger.businessId, { marketId: ledgerMarketId(ledger) }), ledger]) 184 ); 185 if (newestDocument) writes.push(['current-draft-id', newestDocument.id]); 186 187 const exactCommit = storage.commitExact || storage.commit; 188 await exactCommit(writes); 189 190 let projection; 191 try { 192 projection = await rebuildDocumentProjection(storage); 193 } catch (error) { 194 const wrapped = new Error('Workspace data was restored, but rebuilding the document index failed. Reload InvoiceCraftly to retry indexing.'); 195 wrapped.code = 'RESTORE_PROJECTION_REBUILD_FAILED'; 196 wrapped.cause = error; 197 throw wrapped; 198 } 199 200 return { 201 workspaceId: backup.workspace.id, 202 businessCount: backup.businesses.filter((entity) => !entity.deletedAt).length, 203 clientCount: backup.clients.filter((entity) => !entity.deletedAt).length, 204 documentCount: projection.length, 205 paymentCount: backup.payments.filter((entity) => !entity.deletedAt).length, 206 attachmentCount: backup.attachments.length, 207 assetCount: backup.assets.length, 208 numberingLedgerCount: backup.numberingLedgers.length, 209 currentDocumentId: newestDocument?.id || null 210 }; 211}
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.