@mearl/preset 3.0.1 → 3.0.2

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.
@@ -63,6 +63,7 @@ export declare class BrowserBundleStore {
63
63
  installation?: BrowserPresetInstallation;
64
64
  expectedInstallationToken: string | null;
65
65
  }, expectedBundleRevisions?: Record<string, string>): Promise<void>;
66
+ uninstallPreset(expectedRevision: string, expectedInstallationToken: string | null): Promise<void>;
66
67
  /** Verifies all resources before a compare-and-swap transaction replaces the active snapshot. */
67
68
  install(bundle: PreparedBundle, expectedRevision: string | null, expectedBundleRevisions?: Record<string, string>): Promise<void>;
68
69
  remove(id: string, expectedRevision: string): Promise<void>;
@@ -1,8 +1,11 @@
1
- import { BundleError, MAX_BUNDLE_BYTES, MAX_BROWSER_STORAGE_BYTES, MAX_PAGE_RUNTIME_BYTES, bundleDigest, verifyBundle } from './bundle.js';
2
- import { verifyBrowserPreset, verifyPreparedBrowserPreset } from './browserPreset.js';
1
+ import { BundleError, MAX_BUNDLE_BYTES, MAX_BROWSER_STORAGE_BYTES, MAX_PAGE_RUNTIME_BYTES, bundleDigest, verifyBundle, } from './bundle.js';
2
+ import { verifyBrowserPreset, verifyPreparedBrowserPreset, } from './browserPreset.js';
3
3
  import { mergePresetDefaults } from './resolve.js';
4
4
  function installationMetadata(value) {
5
- if (!value || Object.keys(value).some(key => !['revision', 'browserRevision', 'overrides'].includes(key)) || !/^[a-f0-9]{64}$/.test(value.revision) || !/^[a-f0-9]{64}$/.test(value.browserRevision))
5
+ if (!value ||
6
+ Object.keys(value).some(key => !['revision', 'browserRevision', 'overrides'].includes(key)) ||
7
+ !/^[a-f0-9]{64}$/.test(value.revision) ||
8
+ !/^[a-f0-9]{64}$/.test(value.browserRevision))
6
9
  throw new BundleError('BUNDLE_INVALID', 'Invalid browser installation identity');
7
10
  const overrides = mergePresetDefaults({}, value.overrides);
8
11
  if (new TextEncoder().encode(JSON.stringify(overrides)).byteLength > 262144)
@@ -17,18 +20,25 @@ function captureRevisions(input) {
17
20
  if (input === undefined)
18
21
  return;
19
22
  const entries = Object.entries(input);
20
- if (entries.length > 64 || entries.some(([id, revision]) => !/^[a-z][a-z0-9-]{0,79}$/.test(id) || typeof revision !== 'string'))
23
+ if (entries.length > 64 ||
24
+ entries.some(([id, revision]) => !/^[a-z][a-z0-9-]{0,79}$/.test(id) || typeof revision !== 'string'))
21
25
  throw new BundleError('BUNDLE_INVALID', 'Invalid expected browser bundle selection');
22
26
  return Object.fromEntries(entries);
23
27
  }
24
28
  function checkRevisions(records, expected) {
25
- if (expected && (records.length !== Object.keys(expected).length || records.some(record => !Object.hasOwn(expected, record.id) || expected[record.id] !== record.revision)))
29
+ if (expected &&
30
+ (records.length !== Object.keys(expected).length ||
31
+ records.some(record => !Object.hasOwn(expected, record.id) || expected[record.id] !== record.revision)))
26
32
  throw new BundleError('BUNDLE_CONFLICT', 'Browser bundles changed while contributions were being validated');
27
33
  }
28
34
  function storedBundle(bundle) {
29
35
  const { resources, ...metadata } = bundle;
30
- return { id: bundle.manifest.id, revision: bundle.revision,
31
- byteLength: Object.values(resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), bundle: metadata };
36
+ return {
37
+ id: bundle.manifest.id,
38
+ revision: bundle.revision,
39
+ byteLength: Object.values(resources).reduce((sum, bytes) => sum + bytes.byteLength, 0),
40
+ bundle: metadata,
41
+ };
32
42
  }
33
43
  function bundleObjectHashes(records) {
34
44
  const hashes = new Set();
@@ -61,13 +71,17 @@ function readObjects(records, store) {
61
71
  const hashes = bundleObjectHashes(records);
62
72
  for (const hash of hashes) {
63
73
  const request = store.get(hash);
64
- request.onsuccess = () => { if (request.result)
65
- result.set(hash, request.result); };
74
+ request.onsuccess = () => {
75
+ if (request.result)
76
+ result.set(hash, request.result);
77
+ };
66
78
  }
67
79
  return result;
68
80
  }
69
81
  function checkStorageBudget(records, runtimes) {
70
- if (records.some(record => !Number.isSafeInteger(record.byteLength) || record.byteLength < 0 || record.byteLength > MAX_BUNDLE_BYTES) ||
82
+ if (records.some(record => !Number.isSafeInteger(record.byteLength) ||
83
+ record.byteLength < 0 ||
84
+ record.byteLength > MAX_BUNDLE_BYTES) ||
71
85
  runtimes.some(runtime => !(runtime.bytes instanceof Uint8Array) || runtime.bytes.byteLength > MAX_BUNDLE_BYTES)) {
72
86
  throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size is invalid');
73
87
  }
@@ -100,10 +114,16 @@ export class BrowserBundleStore {
100
114
  request.onerror = () => reject(request.error);
101
115
  request.onsuccess = () => {
102
116
  const database = request.result;
103
- database.onversionchange = () => { database.close(); this.database = undefined; };
117
+ database.onversionchange = () => {
118
+ database.close();
119
+ this.database = undefined;
120
+ };
104
121
  resolve(database);
105
122
  };
106
- }).catch(error => { this.database = undefined; throw error; });
123
+ }).catch(error => {
124
+ this.database = undefined;
125
+ throw error;
126
+ });
107
127
  return this.database;
108
128
  }
109
129
  async close() {
@@ -118,11 +138,19 @@ export class BrowserBundleStore {
118
138
  const transaction = database.transaction([STORE, PRESET, OBJECTS], 'readonly');
119
139
  const request = transaction.objectStore(STORE).getAll();
120
140
  let objects = new Map();
121
- request.onsuccess = () => { objects = readObjects(request.result, transaction.objectStore(OBJECTS)); };
141
+ request.onsuccess = () => {
142
+ objects = readObjects(request.result, transaction.objectStore(OBJECTS));
143
+ };
122
144
  const active = transaction.objectStore(PRESET).get('active');
123
145
  const installation = transaction.objectStore(PRESET).get('installation');
124
146
  const token = transaction.objectStore(PRESET).get('installationToken');
125
- transaction.oncomplete = () => resolve({ objects, records: request.result, active: active.result, installation: installation.result, installationToken: token.result ?? null });
147
+ transaction.oncomplete = () => resolve({
148
+ objects,
149
+ records: request.result,
150
+ active: active.result,
151
+ installation: installation.result,
152
+ installationToken: token.result ?? null,
153
+ });
126
154
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
127
155
  });
128
156
  }
@@ -132,7 +160,9 @@ export class BrowserBundleStore {
132
160
  const record = await new Promise((resolve, reject) => {
133
161
  const transaction = database.transaction([STORE, OBJECTS], 'readonly');
134
162
  const request = transaction.objectStore(STORE).get(id);
135
- request.onsuccess = () => { objects = readObjects(request.result ? [request.result] : [], transaction.objectStore(OBJECTS)); };
163
+ request.onsuccess = () => {
164
+ objects = readObjects(request.result ? [request.result] : [], transaction.objectStore(OBJECTS));
165
+ };
136
166
  transaction.oncomplete = () => resolve(request.result);
137
167
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Bundle read failed'));
138
168
  });
@@ -150,7 +180,8 @@ export class BrowserBundleStore {
150
180
  resources[name] = object.bytes;
151
181
  }
152
182
  const bundle = await verifyBundle({ ...record.bundle, resources }, this.options.mearlApiVersion);
153
- if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !== record.byteLength) {
183
+ if (Object.values(bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0) !==
184
+ record.byteLength) {
154
185
  throw new BundleError('BUNDLE_INTEGRITY', 'Stored resource size does not match');
155
186
  }
156
187
  return bundle;
@@ -158,13 +189,16 @@ export class BrowserBundleStore {
158
189
  async list() {
159
190
  const bundles = [];
160
191
  const errors = [];
161
- const { objects, records, active, installation: storedInstallation, installationToken } = await this.records();
192
+ const { objects, records, active, installation: storedInstallation, installationToken, } = await this.records();
162
193
  for (const record of records) {
163
194
  try {
164
195
  bundles.push(await this.verified(record, objects));
165
196
  }
166
197
  catch (error) {
167
- errors.push({ id: record.id, error: error instanceof Error ? error.message : 'Invalid stored bundle' });
198
+ errors.push({
199
+ id: record.id,
200
+ error: error instanceof Error ? error.message : 'Invalid stored bundle',
201
+ });
168
202
  }
169
203
  }
170
204
  let activation;
@@ -181,8 +215,12 @@ export class BrowserBundleStore {
181
215
  }
182
216
  if (activation) {
183
217
  for (const [id, revision] of Object.entries(activation.snapshot.bundleRevisions)) {
184
- if (!bundles.some(bundle => bundle.manifest.id === id && bundle.revision === revision) && !errors.some(error => error.id === id)) {
185
- errors.push({ id, error: 'BUNDLE_INTEGRITY: An active preset package is missing or corrupt' });
218
+ if (!bundles.some(bundle => bundle.manifest.id === id && bundle.revision === revision) &&
219
+ !errors.some(error => error.id === id)) {
220
+ errors.push({
221
+ id,
222
+ error: 'BUNDLE_INTEGRITY: An active preset package is missing or corrupt',
223
+ });
186
224
  }
187
225
  }
188
226
  }
@@ -198,8 +236,17 @@ export class BrowserBundleStore {
198
236
  installationError = error instanceof Error ? error.message : 'Invalid browser installation';
199
237
  }
200
238
  }
201
- return { bundles, errors, activation, activationError, installation, installationError, installationToken, activeRevision: active?.revision ?? null,
202
- revisions: Object.fromEntries(records.map(record => [record.id, record.revision])) };
239
+ return {
240
+ bundles,
241
+ errors,
242
+ activation,
243
+ activationError,
244
+ installation,
245
+ installationError,
246
+ installationToken,
247
+ activeRevision: active?.revision ?? null,
248
+ revisions: Object.fromEntries(records.map(record => [record.id, record.revision])),
249
+ };
203
250
  }
204
251
  async activePreset() {
205
252
  const database = await this.open();
@@ -222,7 +269,10 @@ export class BrowserBundleStore {
222
269
  transaction.oncomplete = () => resolve(request.result);
223
270
  transaction.onabort = transaction.onerror = () => reject(transaction.error ?? new Error('Browser runtime read failed'));
224
271
  });
225
- if (!(record?.bytes instanceof Uint8Array) || record.sha256 !== sha256 || record.bytes.byteLength > MAX_BUNDLE_BYTES || await bundleDigest(record.bytes) !== sha256) {
272
+ if (!(record?.bytes instanceof Uint8Array) ||
273
+ record.sha256 !== sha256 ||
274
+ record.bytes.byteLength > MAX_BUNDLE_BYTES ||
275
+ (await bundleDigest(record.bytes)) !== sha256) {
226
276
  throw new BundleError('BUNDLE_INTEGRITY', 'Pinned browser runtime is missing or corrupt');
227
277
  }
228
278
  return Uint8Array.from(record.bytes);
@@ -248,7 +298,8 @@ export class BrowserBundleStore {
248
298
  }
249
299
  async updateRuntimePins(input, replace) {
250
300
  const patch = Object.entries(input).map(([owner, sha256]) => {
251
- if (!/^[a-zA-Z0-9:._-]{1,200}$/.test(owner) || sha256 !== null && !/^[a-f0-9]{64}$/.test(sha256))
301
+ if (!/^[a-zA-Z0-9:._-]{1,200}$/.test(owner) ||
302
+ (sha256 !== null && !/^[a-f0-9]{64}$/.test(sha256)))
252
303
  throw new BundleError('BUNDLE_INVALID', 'Invalid page runtime reference');
253
304
  return { owner, sha256 };
254
305
  });
@@ -302,7 +353,9 @@ export class BrowserBundleStore {
302
353
  const transaction = database.transaction([PRESET, STORE, OBJECTS], 'readonly');
303
354
  const current = transaction.objectStore(PRESET).get('active');
304
355
  const bundle = transaction.objectStore(STORE).get(id);
305
- bundle.onsuccess = () => { objects = readObjects(bundle.result ? [bundle.result] : [], transaction.objectStore(OBJECTS)); };
356
+ bundle.onsuccess = () => {
357
+ objects = readObjects(bundle.result ? [bundle.result] : [], transaction.objectStore(OBJECTS));
358
+ };
306
359
  transaction.oncomplete = () => {
307
360
  if (current.result?.revision !== activation.snapshot.revision) {
308
361
  reject(new BundleError('BUNDLE_CONFLICT', 'The selected preset changed during profile lookup'));
@@ -320,10 +373,18 @@ export class BrowserBundleStore {
320
373
  /** Packages and the exact preset graph become visible together, after host contribution checks. */
321
374
  async activatePreset(input, replacements, expectedRevision, association, expectedBundleRevisions) {
322
375
  const selection = captureRevisions(expectedBundleRevisions);
323
- const accepted = association?.installation && { installation: installationMetadata(association.installation), expected: association.expectedInstallationToken };
324
- if (association && association.expectedInstallationToken !== null && !/^[a-f0-9]{64}$/.test(association.expectedInstallationToken) || accepted && accepted.installation.browserRevision !== input.revision)
376
+ const accepted = association?.installation && {
377
+ installation: installationMetadata(association.installation),
378
+ expected: association.expectedInstallationToken,
379
+ };
380
+ if ((association &&
381
+ association.expectedInstallationToken !== null &&
382
+ !/^[a-f0-9]{64}$/.test(association.expectedInstallationToken)) ||
383
+ (accepted && accepted.installation.browserRevision !== input.revision))
325
384
  throw new BundleError('BUNDLE_INVALID', 'Invalid browser installation selection');
326
- if (replacements.length > 64 || replacements.reduce((total, item) => total + Object.values(item.bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), 0) > MAX_BROWSER_STORAGE_BYTES) {
385
+ if (replacements.length > 64 ||
386
+ replacements.reduce((total, item) => total +
387
+ Object.values(item.bundle.resources).reduce((sum, bytes) => sum + bytes.byteLength, 0), 0) > MAX_BROWSER_STORAGE_BYTES) {
327
388
  throw new BundleError('BUNDLE_QUOTA', 'Preset packages exceed the browser storage budget');
328
389
  }
329
390
  const expected = replacements.map(item => item.expectedRevision);
@@ -351,14 +412,20 @@ export class BrowserBundleStore {
351
412
  transaction.abort();
352
413
  return;
353
414
  }
354
- if ((active.result?.revision ?? null) !== expectedRevision || association && (installation.result ?? null) !== association.expectedInstallationToken || next.some(item => (records.result.find(record => record.id === item.bundle.manifest.id)?.revision ?? null) !== item.expectedRevision)) {
415
+ if ((active.result?.revision ?? null) !== expectedRevision ||
416
+ (association &&
417
+ (installation.result ?? null) !== association.expectedInstallationToken) ||
418
+ next.some(item => (records.result.find(record => record.id === item.bundle.manifest.id)?.revision ??
419
+ null) !== item.expectedRevision)) {
355
420
  failure = new BundleError('BUNDLE_CONFLICT', 'Preset or packages changed while activation was being prepared');
356
421
  transaction.abort();
357
422
  return;
358
423
  }
359
424
  const replacing = new Set(next.map(item => item.bundle.manifest.id));
360
425
  const previousManaged = new Set(Object.keys(active.result?.bundleRevisions ?? {}));
361
- if (accepted && next.some(item => !previousManaged.has(item.bundle.manifest.id) && records.result.some(record => record.id === item.bundle.manifest.id))) {
426
+ if (accepted &&
427
+ next.some(item => !previousManaged.has(item.bundle.manifest.id) &&
428
+ records.result.some(record => record.id === item.bundle.manifest.id))) {
362
429
  failure = new BundleError('BUNDLE_CONFLICT', 'A preset package conflicts with an independently installed browser package');
363
430
  transaction.abort();
364
431
  return;
@@ -405,6 +472,47 @@ export class BrowserBundleStore {
405
472
  transaction.onabort = transaction.onerror = () => reject(failure ?? transaction.error ?? new Error('Preset activation failed'));
406
473
  });
407
474
  }
475
+ async uninstallPreset(expectedRevision, expectedInstallationToken) {
476
+ const selection = await this.activePreset();
477
+ if (selection?.snapshot.revision !== expectedRevision)
478
+ throw new BundleError('BUNDLE_CONFLICT', 'The browser preset changed before removal');
479
+ const managed = selection.snapshot.bundleRevisions;
480
+ const database = await this.open();
481
+ await new Promise((resolve, reject) => {
482
+ const transaction = database.transaction([STORE, PRESET, OBJECTS, PINS], 'readwrite');
483
+ const store = transaction.objectStore(STORE);
484
+ const presets = transaction.objectStore(PRESET);
485
+ const objects = transaction.objectStore(OBJECTS);
486
+ const records = store.getAll();
487
+ const retained = objects.getAll();
488
+ const pins = transaction.objectStore(PINS).getAll();
489
+ const token = presets.get('installationToken');
490
+ const active = presets.get('active');
491
+ let failure;
492
+ active.onsuccess = () => {
493
+ if (active.result?.revision !== expectedRevision ||
494
+ (token.result ?? null) !== expectedInstallationToken) {
495
+ failure = new BundleError('BUNDLE_CONFLICT', 'The browser preset changed before removal');
496
+ transaction.abort();
497
+ return;
498
+ }
499
+ const remaining = records.result.filter(record => {
500
+ if (!Object.hasOwn(managed, record.id) || managed[record.id] !== record.revision)
501
+ return true;
502
+ store.delete(record.id);
503
+ return false;
504
+ });
505
+ const keep = new Set(referencedObjects(remaining, retained.result, pins.result).map(object => object.sha256));
506
+ for (const object of retained.result)
507
+ if (!keep.has(object.sha256))
508
+ objects.delete(object.sha256);
509
+ for (const key of ['active', 'installation', 'installationToken'])
510
+ presets.delete(key);
511
+ };
512
+ transaction.oncomplete = () => resolve();
513
+ transaction.onabort = transaction.onerror = () => reject(failure ?? transaction.error ?? new Error('Browser preset removal failed'));
514
+ });
515
+ }
408
516
  /** Verifies all resources before a compare-and-swap transaction replaces the active snapshot. */
409
517
  async install(bundle, expectedRevision, expectedBundleRevisions) {
410
518
  const selection = captureRevisions(expectedBundleRevisions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mearl/preset",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Browser-compatible preset validation, inheritance and capability resolution for Mearl",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",