@ifc-lite/viewer 1.23.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +34 -31
- package/CHANGELOG.md +96 -0
- package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
- package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
- package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
- package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
- package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
- package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
- package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
- package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
- package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
- package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
- package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
- package/dist/assets/index-Bws3UAkj.css +1 -0
- package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
- package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
- package/dist/assets/lens-PYsLu_MA.js +1 -0
- package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
- package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
- package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
- package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
- package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
- package/dist/assets/raw-CoIXstQ-.js +1 -0
- package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
- package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
- package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
- package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
- package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
- package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +11 -9
- package/src/App.tsx +5 -2
- package/src/components/extensions/AuditLogPanel.tsx +259 -0
- package/src/components/extensions/BundlePreview.tsx +102 -0
- package/src/components/extensions/CapabilityReview.tsx +333 -0
- package/src/components/extensions/ExtensionDockHost.tsx +192 -0
- package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
- package/src/components/extensions/ExtensionsPanel.tsx +481 -0
- package/src/components/extensions/FlavorDialog.tsx +398 -0
- package/src/components/extensions/FlavorImportPreview.tsx +79 -0
- package/src/components/extensions/FlavorIndicator.tsx +81 -0
- package/src/components/extensions/FlavorListView.tsx +318 -0
- package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
- package/src/components/extensions/HelpHint.tsx +182 -0
- package/src/components/extensions/IdeasPanel.tsx +344 -0
- package/src/components/extensions/PlanCard.tsx +227 -0
- package/src/components/extensions/PrivacyPanel.tsx +312 -0
- package/src/components/extensions/PromoteToolDialog.tsx +313 -0
- package/src/components/extensions/RepairQueuePanel.tsx +222 -0
- package/src/components/extensions/icon-registry.ts +92 -0
- package/src/components/extensions/toast-helpers.ts +49 -0
- package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
- package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
- package/src/components/viewer/ChatPanel.tsx +251 -3
- package/src/components/viewer/CommandPalette.tsx +74 -4
- package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
- package/src/components/viewer/EntityContextMenu.tsx +70 -0
- package/src/components/viewer/ExportDialog.tsx +9 -1
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
- package/src/components/viewer/LensPanel.tsx +50 -0
- package/src/components/viewer/MainToolbar.tsx +170 -87
- package/src/components/viewer/ScriptPanel.tsx +105 -1
- package/src/components/viewer/Section2DPanel.tsx +58 -2
- package/src/components/viewer/StatusBar.tsx +18 -0
- package/src/components/viewer/ViewerLayout.tsx +53 -4
- package/src/components/viewer/Viewport.tsx +72 -0
- package/src/hooks/useActionLogger.test.ts +161 -0
- package/src/hooks/useActionLogger.ts +141 -0
- package/src/hooks/useForkExtension.ts +51 -0
- package/src/hooks/useIfcFederation.ts +7 -1
- package/src/hooks/useInstalledExtensions.ts +43 -0
- package/src/hooks/usePrivacyDisclosure.ts +48 -0
- package/src/hooks/useRunExtensionTests.ts +67 -0
- package/src/hooks/useSlotContributions.ts +38 -0
- package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
- package/src/hooks/useSymbolicAnnotations.ts +776 -0
- package/src/lib/desktop-product.ts +7 -1
- package/src/lib/lens/adapter.ts +14 -0
- package/src/lib/llm/prompt-cache.ts +77 -0
- package/src/lib/llm/stream-client.ts +20 -2
- package/src/lib/llm/stream-direct.ts +11 -1
- package/src/lib/llm/system-prompt.ts +42 -0
- package/src/lib/safe-mode.ts +30 -0
- package/src/sdk/ExtensionHostProvider.tsx +103 -0
- package/src/services/extensions/flavor-service.ts +183 -0
- package/src/services/extensions/host-commands.ts +112 -0
- package/src/services/extensions/host-installer.ts +289 -0
- package/src/services/extensions/host.ts +514 -0
- package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
- package/src/services/extensions/idb-flavor-storage.ts +241 -0
- package/src/services/extensions/idb-log-storage.test.ts +110 -0
- package/src/services/extensions/idb-log-storage.ts +171 -0
- package/src/services/extensions/idb-storage.ts +228 -0
- package/src/services/extensions/runtime-errors.ts +26 -0
- package/src/services/extensions/sandbox-factory.ts +217 -0
- package/src/store/constants.ts +48 -6
- package/src/store/index.ts +6 -1
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/extensionsSlice.ts +90 -0
- package/src/store/slices/lensSlice.ts +28 -0
- package/src/store/slices/visibilitySlice.test.ts +6 -0
- package/src/store/slices/visibilitySlice.ts +17 -8
- package/src/store/types.ts +2 -0
- package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
- package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
- package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
- package/dist/assets/index-DS_xJQfP.css +0 -1
- package/dist/assets/lens-CpjUdqpw.js +0 -1
- package/dist/assets/raw-DzTtEZIY.js +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* IndexedDB-backed implementation of the `FlavorStorage` interface
|
|
7
|
+
* from `@ifc-lite/extensions`.
|
|
8
|
+
*
|
|
9
|
+
* Three object stores:
|
|
10
|
+
* - `flavors` keyed by flavor id → Flavor
|
|
11
|
+
* - `flavor-active` single-row key 'active' → { id }
|
|
12
|
+
* - `flavor-snaps` keyed by `<flavorId>@<seq>` → FlavorSnapshot
|
|
13
|
+
*
|
|
14
|
+
* The schema mirrors the in-memory storage in `@ifc-lite/extensions/flavor/storage.ts`
|
|
15
|
+
* but persists across reloads. Snapshot cap of 10 per flavor is enforced
|
|
16
|
+
* client-side (same as the in-memory impl).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Flavor, FlavorSnapshot, FlavorStorage } from '@ifc-lite/extensions';
|
|
20
|
+
import { ExtensionStorageQuotaError } from './idb-storage.js';
|
|
21
|
+
|
|
22
|
+
const DB_NAME = 'ifc-lite-flavors';
|
|
23
|
+
/** See idb-storage.ts for the migration policy. */
|
|
24
|
+
const DB_VERSION = 1;
|
|
25
|
+
const STORE_FLAVORS = 'flavors';
|
|
26
|
+
const STORE_ACTIVE = 'flavor-active';
|
|
27
|
+
const STORE_SNAPS = 'flavor-snaps';
|
|
28
|
+
const SNAPSHOT_CAP = 10;
|
|
29
|
+
const ACTIVE_KEY = 'active';
|
|
30
|
+
|
|
31
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
32
|
+
|
|
33
|
+
interface SnapshotRow extends FlavorSnapshot {
|
|
34
|
+
/** Compound key `<flavorId>@<seq>` so IDB can index without composite keys. */
|
|
35
|
+
key: string;
|
|
36
|
+
flavorId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve once a readwrite transaction commits. Rejects on both
|
|
41
|
+
* `error` AND `abort` — an aborted transaction fires neither
|
|
42
|
+
* `oncomplete` nor `onerror`, so without the `onabort` branch the
|
|
43
|
+
* returned promise would hang forever.
|
|
44
|
+
*/
|
|
45
|
+
function txDone(tx: IDBTransaction): Promise<void> {
|
|
46
|
+
return new Promise<void>((resolve, reject) => {
|
|
47
|
+
tx.oncomplete = () => resolve();
|
|
48
|
+
tx.onerror = () => reject(tx.error ?? new Error('Flavor IDB transaction failed.'));
|
|
49
|
+
tx.onabort = () => reject(tx.error ?? new Error('Flavor IDB transaction aborted.'));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isQuotaError(err: unknown): boolean {
|
|
54
|
+
if (!err || typeof err !== 'object') return false;
|
|
55
|
+
const name = (err as { name?: unknown }).name;
|
|
56
|
+
return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function withQuotaGuard<T>(operation: string, fn: () => Promise<T>): Promise<T> {
|
|
60
|
+
try {
|
|
61
|
+
return await fn();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (isQuotaError(err)) {
|
|
64
|
+
throw new ExtensionStorageQuotaError(operation, err);
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class IdbFlavorStorage implements FlavorStorage {
|
|
71
|
+
async putFlavor(flavor: Flavor, reason?: string): Promise<void> {
|
|
72
|
+
const db = await openDatabase();
|
|
73
|
+
const previous = await this.getFlavor(flavor.id);
|
|
74
|
+
if (previous) {
|
|
75
|
+
await this.recordSnapshot(db, previous, reason);
|
|
76
|
+
}
|
|
77
|
+
await withQuotaGuard(`saving flavor "${flavor.id}"`, async () => {
|
|
78
|
+
const tx = db.transaction(STORE_FLAVORS, 'readwrite');
|
|
79
|
+
tx.objectStore(STORE_FLAVORS).put(flavor);
|
|
80
|
+
await txDone(tx);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async getFlavor(id: string): Promise<Flavor | undefined> {
|
|
85
|
+
const db = await openDatabase();
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const tx = db.transaction(STORE_FLAVORS, 'readonly');
|
|
88
|
+
const req = tx.objectStore(STORE_FLAVORS).get(id);
|
|
89
|
+
req.onsuccess = () => resolve((req.result as Flavor | undefined) ?? undefined);
|
|
90
|
+
req.onerror = () => reject(req.error);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async listFlavors(): Promise<Flavor[]> {
|
|
95
|
+
const db = await openDatabase();
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const tx = db.transaction(STORE_FLAVORS, 'readonly');
|
|
98
|
+
const req = tx.objectStore(STORE_FLAVORS).getAll();
|
|
99
|
+
req.onsuccess = () => resolve((req.result as Flavor[] | undefined) ?? []);
|
|
100
|
+
req.onerror = () => reject(req.error);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async deleteFlavor(id: string): Promise<void> {
|
|
105
|
+
const db = await openDatabase();
|
|
106
|
+
// Drop the flavor itself + cascade its snapshots.
|
|
107
|
+
const tx = db.transaction([STORE_FLAVORS, STORE_SNAPS], 'readwrite');
|
|
108
|
+
tx.objectStore(STORE_FLAVORS).delete(id);
|
|
109
|
+
const snaps = tx.objectStore(STORE_SNAPS);
|
|
110
|
+
const cursorReq = snaps.openCursor();
|
|
111
|
+
cursorReq.onsuccess = () => {
|
|
112
|
+
const cursor = cursorReq.result;
|
|
113
|
+
if (!cursor) return;
|
|
114
|
+
const row = cursor.value as SnapshotRow;
|
|
115
|
+
if (row.flavorId === id) cursor.delete();
|
|
116
|
+
cursor.continue();
|
|
117
|
+
};
|
|
118
|
+
await txDone(tx);
|
|
119
|
+
// If we deleted the active flavor, clear the pointer.
|
|
120
|
+
const activeId = await this.getActiveId();
|
|
121
|
+
if (activeId === id) await this.setActiveId(undefined);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getActiveId(): Promise<string | undefined> {
|
|
125
|
+
const db = await openDatabase();
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const tx = db.transaction(STORE_ACTIVE, 'readonly');
|
|
128
|
+
const req = tx.objectStore(STORE_ACTIVE).get(ACTIVE_KEY);
|
|
129
|
+
req.onsuccess = () => resolve((req.result as { id?: string } | undefined)?.id);
|
|
130
|
+
req.onerror = () => reject(req.error);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async setActiveId(id: string | undefined): Promise<void> {
|
|
135
|
+
const db = await openDatabase();
|
|
136
|
+
const tx = db.transaction(STORE_ACTIVE, 'readwrite');
|
|
137
|
+
const store = tx.objectStore(STORE_ACTIVE);
|
|
138
|
+
if (id === undefined) {
|
|
139
|
+
store.delete(ACTIVE_KEY);
|
|
140
|
+
} else {
|
|
141
|
+
store.put({ key: ACTIVE_KEY, id });
|
|
142
|
+
}
|
|
143
|
+
await txDone(tx);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async listSnapshots(flavorId: string): Promise<FlavorSnapshot[]> {
|
|
147
|
+
return (await this.listSnapshotRows(flavorId))
|
|
148
|
+
.map(({ key, flavorId: _id, ...rest }) => rest as FlavorSnapshot);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async restoreSnapshot(flavorId: string, seq: number, reason?: string): Promise<Flavor | undefined> {
|
|
152
|
+
const db = await openDatabase();
|
|
153
|
+
const snap = await new Promise<SnapshotRow | undefined>((resolve, reject) => {
|
|
154
|
+
const tx = db.transaction(STORE_SNAPS, 'readonly');
|
|
155
|
+
const req = tx.objectStore(STORE_SNAPS).get(`${flavorId}@${seq}`);
|
|
156
|
+
req.onsuccess = () => resolve(req.result as SnapshotRow | undefined);
|
|
157
|
+
req.onerror = () => reject(req.error);
|
|
158
|
+
});
|
|
159
|
+
if (!snap) return undefined;
|
|
160
|
+
await this.putFlavor(snap.flavor, reason ?? 'restored from snapshot');
|
|
161
|
+
return snap.flavor;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async clear(): Promise<void> {
|
|
165
|
+
const db = await openDatabase();
|
|
166
|
+
const tx = db.transaction([STORE_FLAVORS, STORE_ACTIVE, STORE_SNAPS], 'readwrite');
|
|
167
|
+
tx.objectStore(STORE_FLAVORS).clear();
|
|
168
|
+
tx.objectStore(STORE_ACTIVE).clear();
|
|
169
|
+
tx.objectStore(STORE_SNAPS).clear();
|
|
170
|
+
await txDone(tx);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Raw snapshot rows for a flavor, newest seq first. */
|
|
174
|
+
private async listSnapshotRows(flavorId: string): Promise<SnapshotRow[]> {
|
|
175
|
+
const db = await openDatabase();
|
|
176
|
+
const all = await new Promise<SnapshotRow[]>((resolve, reject) => {
|
|
177
|
+
const tx = db.transaction(STORE_SNAPS, 'readonly');
|
|
178
|
+
const req = tx.objectStore(STORE_SNAPS).getAll();
|
|
179
|
+
req.onsuccess = () => resolve((req.result as SnapshotRow[] | undefined) ?? []);
|
|
180
|
+
req.onerror = () => reject(req.error);
|
|
181
|
+
});
|
|
182
|
+
return all
|
|
183
|
+
.filter((s) => s.flavorId === flavorId)
|
|
184
|
+
.sort((a, b) => b.seq - a.seq);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async recordSnapshot(
|
|
188
|
+
db: IDBDatabase,
|
|
189
|
+
flavor: Flavor,
|
|
190
|
+
reason: string | undefined,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
// Derive the next seq from the snapshots already persisted for
|
|
193
|
+
// this flavor. A reload-resettable in-memory counter would restart
|
|
194
|
+
// at 1 and overwrite existing `<flavorId>@1`, `@2`, … rows.
|
|
195
|
+
const existing = await this.listSnapshotRows(flavor.id);
|
|
196
|
+
const seq = existing.reduce((max, s) => Math.max(max, s.seq), 0) + 1;
|
|
197
|
+
const row: SnapshotRow = {
|
|
198
|
+
key: `${flavor.id}@${seq}`,
|
|
199
|
+
flavorId: flavor.id,
|
|
200
|
+
seq,
|
|
201
|
+
capturedAt: new Date().toISOString(),
|
|
202
|
+
flavor,
|
|
203
|
+
reason,
|
|
204
|
+
};
|
|
205
|
+
const putTx = db.transaction(STORE_SNAPS, 'readwrite');
|
|
206
|
+
putTx.objectStore(STORE_SNAPS).put(row);
|
|
207
|
+
await txDone(putTx);
|
|
208
|
+
// Enforce cap: keep newest SNAPSHOT_CAP rows per flavor.
|
|
209
|
+
const all = [row, ...existing];
|
|
210
|
+
if (all.length > SNAPSHOT_CAP) {
|
|
211
|
+
const toDrop = all.slice(SNAPSHOT_CAP);
|
|
212
|
+
const dropTx = db.transaction(STORE_SNAPS, 'readwrite');
|
|
213
|
+
const store = dropTx.objectStore(STORE_SNAPS);
|
|
214
|
+
for (const s of toDrop) store.delete(`${flavor.id}@${s.seq}`);
|
|
215
|
+
await txDone(dropTx);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function openDatabase(): Promise<IDBDatabase> {
|
|
221
|
+
if (dbPromise) return dbPromise;
|
|
222
|
+
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
223
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
224
|
+
req.onupgradeneeded = () => {
|
|
225
|
+
const db = req.result;
|
|
226
|
+
if (!db.objectStoreNames.contains(STORE_FLAVORS)) {
|
|
227
|
+
db.createObjectStore(STORE_FLAVORS, { keyPath: 'id' });
|
|
228
|
+
}
|
|
229
|
+
if (!db.objectStoreNames.contains(STORE_ACTIVE)) {
|
|
230
|
+
db.createObjectStore(STORE_ACTIVE, { keyPath: 'key' });
|
|
231
|
+
}
|
|
232
|
+
if (!db.objectStoreNames.contains(STORE_SNAPS)) {
|
|
233
|
+
db.createObjectStore(STORE_SNAPS, { keyPath: 'key' });
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
req.onsuccess = () => resolve(req.result);
|
|
237
|
+
req.onerror = () => reject(req.error);
|
|
238
|
+
req.onblocked = () => reject(new Error('Flavor IDB open blocked by another tab.'));
|
|
239
|
+
});
|
|
240
|
+
return dbPromise;
|
|
241
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
import 'fake-indexeddb/auto';
|
|
6
|
+
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { beforeEach, describe, it } from 'node:test';
|
|
9
|
+
import type { ActionEvent, AuditEvent } from '@ifc-lite/extensions';
|
|
10
|
+
import { IdbLogStorage } from './idb-log-storage.js';
|
|
11
|
+
|
|
12
|
+
function actionEvent(seq: number, intent: ActionEvent['intent'] = 'model.unload'): ActionEvent {
|
|
13
|
+
return {
|
|
14
|
+
seq,
|
|
15
|
+
ts: new Date(2026, 0, 1, 0, 0, seq).toISOString(),
|
|
16
|
+
intent,
|
|
17
|
+
params: {} as ActionEvent['params'],
|
|
18
|
+
success: true,
|
|
19
|
+
} as ActionEvent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function auditEvent(seq: number, id: string = 'com.example.ext'): AuditEvent {
|
|
23
|
+
return {
|
|
24
|
+
seq,
|
|
25
|
+
ts: new Date(2026, 0, 1, 0, 0, seq).toISOString(),
|
|
26
|
+
kind: 'install',
|
|
27
|
+
extensionId: id,
|
|
28
|
+
version: '1.0.0',
|
|
29
|
+
grantedCapabilities: [],
|
|
30
|
+
} as AuditEvent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FLUSH_WAIT_MS = 400; // debounce is 250 ms; wait a bit longer.
|
|
34
|
+
|
|
35
|
+
async function sleep(ms: number): Promise<void> {
|
|
36
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('IdbLogStorage', () => {
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
const store = new IdbLogStorage();
|
|
42
|
+
await store.clearActions();
|
|
43
|
+
await store.clearAudit();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('round-trips action events through debounced writes', async () => {
|
|
47
|
+
const store = new IdbLogStorage();
|
|
48
|
+
store.appendAction(actionEvent(1));
|
|
49
|
+
store.appendAction(actionEvent(2));
|
|
50
|
+
store.appendAction(actionEvent(3));
|
|
51
|
+
await sleep(FLUSH_WAIT_MS);
|
|
52
|
+
const loaded = await store.loadActions();
|
|
53
|
+
assert.strictEqual(loaded.length, 3);
|
|
54
|
+
assert.deepStrictEqual(loaded.map((e) => e.seq).sort((a, b) => a - b), [1, 2, 3]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('round-trips audit events', async () => {
|
|
58
|
+
const store = new IdbLogStorage();
|
|
59
|
+
store.appendAudit(auditEvent(1));
|
|
60
|
+
store.appendAudit(auditEvent(2));
|
|
61
|
+
await sleep(FLUSH_WAIT_MS);
|
|
62
|
+
const loaded = await store.loadAudit();
|
|
63
|
+
assert.strictEqual(loaded.length, 2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('clearActions wipes the action store but leaves audit alone', async () => {
|
|
67
|
+
const store = new IdbLogStorage();
|
|
68
|
+
store.appendAction(actionEvent(1));
|
|
69
|
+
store.appendAudit(auditEvent(1));
|
|
70
|
+
await sleep(FLUSH_WAIT_MS);
|
|
71
|
+
await store.clearActions();
|
|
72
|
+
assert.strictEqual((await store.loadActions()).length, 0);
|
|
73
|
+
assert.strictEqual((await store.loadAudit()).length, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('clearAudit wipes the audit store but leaves actions alone', async () => {
|
|
77
|
+
const store = new IdbLogStorage();
|
|
78
|
+
store.appendAction(actionEvent(1));
|
|
79
|
+
store.appendAudit(auditEvent(1));
|
|
80
|
+
await sleep(FLUSH_WAIT_MS);
|
|
81
|
+
await store.clearAudit();
|
|
82
|
+
assert.strictEqual((await store.loadAudit()).length, 0);
|
|
83
|
+
assert.strictEqual((await store.loadActions()).length, 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('debounces a burst into one flush', async () => {
|
|
87
|
+
const store = new IdbLogStorage();
|
|
88
|
+
// Append 20 events in a tight loop — all should land after one
|
|
89
|
+
// debounce window expires.
|
|
90
|
+
for (let i = 1; i <= 20; i++) {
|
|
91
|
+
store.appendAction(actionEvent(i));
|
|
92
|
+
}
|
|
93
|
+
// Before the debounce fires nothing is in storage yet.
|
|
94
|
+
assert.strictEqual((await store.loadActions()).length, 0);
|
|
95
|
+
await sleep(FLUSH_WAIT_MS);
|
|
96
|
+
assert.strictEqual((await store.loadActions()).length, 20);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('preserves the seq-based key uniqueness', async () => {
|
|
100
|
+
const store = new IdbLogStorage();
|
|
101
|
+
// Re-appending the same seq overwrites (keyPath: 'seq').
|
|
102
|
+
store.appendAction({ ...actionEvent(1), params: { type: 'foo' } } as ActionEvent);
|
|
103
|
+
await sleep(FLUSH_WAIT_MS);
|
|
104
|
+
store.appendAction({ ...actionEvent(1), params: { type: 'bar' } } as ActionEvent);
|
|
105
|
+
await sleep(FLUSH_WAIT_MS);
|
|
106
|
+
const loaded = await store.loadActions();
|
|
107
|
+
assert.strictEqual(loaded.length, 1);
|
|
108
|
+
assert.strictEqual((loaded[0].params as { type?: string }).type, 'bar');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* IndexedDB-backed persistence for the action log and audit log.
|
|
7
|
+
*
|
|
8
|
+
* Both logs are append-only ring buffers in memory. This module mirrors
|
|
9
|
+
* each appended event into IDB so the history survives reloads, and
|
|
10
|
+
* loads the prior snapshot on app boot via the logs' `hydrate()`
|
|
11
|
+
* methods.
|
|
12
|
+
*
|
|
13
|
+
* Two object stores:
|
|
14
|
+
* - `action-events` keyed by `seq` → ActionEvent
|
|
15
|
+
* - `audit-events` keyed by `seq` → AuditEvent
|
|
16
|
+
*
|
|
17
|
+
* Writes are debounced (250 ms) to keep the IDB hit rate down during
|
|
18
|
+
* fast bursts; the in-memory log is the source of truth between
|
|
19
|
+
* debounce flushes.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ActionEvent, AuditEvent } from '@ifc-lite/extensions';
|
|
23
|
+
|
|
24
|
+
const DB_NAME = 'ifc-lite-extension-logs';
|
|
25
|
+
const DB_VERSION = 1;
|
|
26
|
+
const STORE_ACTION = 'action-events';
|
|
27
|
+
const STORE_AUDIT = 'audit-events';
|
|
28
|
+
const DEBOUNCE_MS = 250;
|
|
29
|
+
|
|
30
|
+
let dbPromise: Promise<IDBDatabase> | null = null;
|
|
31
|
+
|
|
32
|
+
export class IdbLogStorage {
|
|
33
|
+
private actionPending: ActionEvent[] = [];
|
|
34
|
+
private auditPending: AuditEvent[] = [];
|
|
35
|
+
private actionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
private auditTimer: ReturnType<typeof setTimeout> | null = null;
|
|
37
|
+
|
|
38
|
+
async loadActions(): Promise<ActionEvent[]> {
|
|
39
|
+
return loadAll<ActionEvent>(STORE_ACTION);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async loadAudit(): Promise<AuditEvent[]> {
|
|
43
|
+
return loadAll<AuditEvent>(STORE_AUDIT);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Queue an action event for persistence; flushes after DEBOUNCE_MS. */
|
|
47
|
+
appendAction(event: ActionEvent): void {
|
|
48
|
+
this.actionPending.push(event);
|
|
49
|
+
if (this.actionTimer) clearTimeout(this.actionTimer);
|
|
50
|
+
this.actionTimer = setTimeout(() => {
|
|
51
|
+
void this.flushActions();
|
|
52
|
+
}, DEBOUNCE_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Queue an audit event for persistence; flushes after DEBOUNCE_MS. */
|
|
56
|
+
appendAudit(event: AuditEvent): void {
|
|
57
|
+
this.auditPending.push(event);
|
|
58
|
+
if (this.auditTimer) clearTimeout(this.auditTimer);
|
|
59
|
+
this.auditTimer = setTimeout(() => {
|
|
60
|
+
void this.flushAudit();
|
|
61
|
+
}, DEBOUNCE_MS);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wipe the action store (called when the user clears the log). */
|
|
65
|
+
async clearActions(): Promise<void> {
|
|
66
|
+
const db = await openDatabase();
|
|
67
|
+
await new Promise<void>((resolve, reject) => {
|
|
68
|
+
const tx = db.transaction(STORE_ACTION, 'readwrite');
|
|
69
|
+
tx.objectStore(STORE_ACTION).clear();
|
|
70
|
+
bindTx(tx, resolve, reject);
|
|
71
|
+
});
|
|
72
|
+
this.actionPending = [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async clearAudit(): Promise<void> {
|
|
76
|
+
const db = await openDatabase();
|
|
77
|
+
await new Promise<void>((resolve, reject) => {
|
|
78
|
+
const tx = db.transaction(STORE_AUDIT, 'readwrite');
|
|
79
|
+
tx.objectStore(STORE_AUDIT).clear();
|
|
80
|
+
bindTx(tx, resolve, reject);
|
|
81
|
+
});
|
|
82
|
+
this.auditPending = [];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Flush both pending batches immediately and cancel the debounce
|
|
87
|
+
* timers. Call on host teardown / before reload so events appended
|
|
88
|
+
* within the last DEBOUNCE_MS window aren't lost and the timers
|
|
89
|
+
* don't leak.
|
|
90
|
+
*/
|
|
91
|
+
async flush(): Promise<void> {
|
|
92
|
+
if (this.actionTimer) { clearTimeout(this.actionTimer); this.actionTimer = null; }
|
|
93
|
+
if (this.auditTimer) { clearTimeout(this.auditTimer); this.auditTimer = null; }
|
|
94
|
+
await Promise.all([this.flushActions(), this.flushAudit()]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async flushActions(): Promise<void> {
|
|
98
|
+
if (this.actionPending.length === 0) return;
|
|
99
|
+
const batch = this.actionPending;
|
|
100
|
+
this.actionPending = [];
|
|
101
|
+
try {
|
|
102
|
+
const db = await openDatabase();
|
|
103
|
+
await new Promise<void>((resolve, reject) => {
|
|
104
|
+
const tx = db.transaction(STORE_ACTION, 'readwrite');
|
|
105
|
+
const store = tx.objectStore(STORE_ACTION);
|
|
106
|
+
for (const event of batch) store.put(event);
|
|
107
|
+
bindTx(tx, resolve, reject);
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.warn('[IdbLogStorage] action flush failed:', err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async flushAudit(): Promise<void> {
|
|
115
|
+
if (this.auditPending.length === 0) return;
|
|
116
|
+
const batch = this.auditPending;
|
|
117
|
+
this.auditPending = [];
|
|
118
|
+
try {
|
|
119
|
+
const db = await openDatabase();
|
|
120
|
+
await new Promise<void>((resolve, reject) => {
|
|
121
|
+
const tx = db.transaction(STORE_AUDIT, 'readwrite');
|
|
122
|
+
const store = tx.objectStore(STORE_AUDIT);
|
|
123
|
+
for (const event of batch) store.put(event);
|
|
124
|
+
bindTx(tx, resolve, reject);
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.warn('[IdbLogStorage] audit flush failed:', err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Settle a readwrite transaction. The `abort` branch matters: an
|
|
134
|
+
* aborted transaction fires neither `complete` nor `error`, so the
|
|
135
|
+
* promise would otherwise hang forever.
|
|
136
|
+
*/
|
|
137
|
+
function bindTx(tx: IDBTransaction, resolve: () => void, reject: (e: unknown) => void): void {
|
|
138
|
+
tx.oncomplete = () => resolve();
|
|
139
|
+
tx.onerror = () => reject(tx.error ?? new Error('Log IDB transaction failed.'));
|
|
140
|
+
tx.onabort = () => reject(tx.error ?? new Error('Log IDB transaction aborted.'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadAll<T>(store: string): Promise<T[]> {
|
|
144
|
+
const db = await openDatabase();
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const tx = db.transaction(store, 'readonly');
|
|
147
|
+
const req = tx.objectStore(store).getAll();
|
|
148
|
+
req.onsuccess = () => resolve(((req.result as T[] | undefined) ?? []));
|
|
149
|
+
req.onerror = () => reject(req.error);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function openDatabase(): Promise<IDBDatabase> {
|
|
154
|
+
if (dbPromise) return dbPromise;
|
|
155
|
+
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
|
156
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
157
|
+
req.onupgradeneeded = () => {
|
|
158
|
+
const db = req.result;
|
|
159
|
+
if (!db.objectStoreNames.contains(STORE_ACTION)) {
|
|
160
|
+
db.createObjectStore(STORE_ACTION, { keyPath: 'seq' });
|
|
161
|
+
}
|
|
162
|
+
if (!db.objectStoreNames.contains(STORE_AUDIT)) {
|
|
163
|
+
db.createObjectStore(STORE_AUDIT, { keyPath: 'seq' });
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
req.onsuccess = () => resolve(req.result);
|
|
167
|
+
req.onerror = () => reject(req.error);
|
|
168
|
+
req.onblocked = () => reject(new Error('Log IDB open blocked by another tab.'));
|
|
169
|
+
});
|
|
170
|
+
return dbPromise;
|
|
171
|
+
}
|