PageSourceSearch

https://invoicecraftly.com/_assets/js/features/workspace-domain/workspace-backup-service.8354d5968e30.js

js invoicecraftly.com collected 2026-09-24 14:47:48 UTC 9,036 bytes, 211 lines download raw bytes

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.