strata-storage 2.8.5 → 3.0.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.
@@ -116,6 +116,9 @@ export class Strata {
116
116
  this._ttlCleanupTimer = setInterval(() => {
117
117
  void this.cleanupAllAdapters();
118
118
  }, interval);
119
+ // See BaseAdapter.startTTLCleanup — a live interval must not keep a Node
120
+ // process alive. No-op in browsers.
121
+ this._ttlCleanupTimer.unref?.();
119
122
  }
120
123
  // Start periodic auto-backup if configured
121
124
  if (this.config.autoBackup?.interval) {
@@ -729,6 +732,8 @@ export class Strata {
729
732
  // Synchronous adapter lookup — falls back to the registry so sync operations
730
733
  // work even before async initialize() has completed.
731
734
  selectAdapterSync(storage) {
735
+ // An explicitly named storage is honoured as asked — the caller chose it, and
736
+ // a hard error naming it is more useful than a silent substitution.
732
737
  if (storage) {
733
738
  const names = Array.isArray(storage) ? storage : [storage];
734
739
  for (const name of names) {
@@ -738,19 +743,51 @@ export class Strata {
738
743
  }
739
744
  throw new StorageError(`No adapter registered for storage type(s): ${names.join(', ')}`);
740
745
  }
741
- if (this.defaultAdapter)
746
+ if (this.defaultAdapter && Strata.isUsableSync(this.defaultAdapter)) {
742
747
  return this.defaultAdapter;
748
+ }
749
+ // 🔴 `defaultStorages` reads as an ordered fallback list, so honour it as
750
+ // one HERE too. It previously guarded only the async path: with
751
+ // localStorage unavailable (SSR, private mode, a blocked cookie policy),
752
+ // `defineStorage({ defaultStorages: ['localStorage','memory'] }).setSync()`
753
+ // still selected localStorage and threw SerializationError rather than
754
+ // falling through to memory, which is the whole point of listing memory.
743
755
  const preferred = this.config.defaultStorages ?? [];
744
756
  for (const name of preferred) {
745
757
  const adapter = this.adapters.get(name) ?? this.registry.get(name);
746
- if (adapter)
758
+ if (adapter && Strata.isUsableSync(adapter))
759
+ return adapter;
760
+ }
761
+ for (const adapter of this.registry.getAll().values()) {
762
+ if (Strata.isUsableSync(adapter))
747
763
  return adapter;
748
764
  }
765
+ // Nothing usable: fall back to the previous behaviour so the caller gets the
766
+ // adapter's own specific error rather than a vague one from here.
767
+ if (this.defaultAdapter)
768
+ return this.defaultAdapter;
749
769
  const first = this.registry.getAll().values().next().value;
750
770
  if (first)
751
771
  return first;
752
772
  throw new StorageError('No storage adapter registered for synchronous operation.');
753
773
  }
774
+ /**
775
+ * Whether an adapter can serve a synchronous operation right now. An adapter
776
+ * with no `isAvailableSync` is assumed usable — absence of a probe is not
777
+ * evidence of unavailability.
778
+ */
779
+ static isUsableSync(adapter) {
780
+ if (!adapter.capabilities.synchronous || !adapter.getSync)
781
+ return false;
782
+ if (!adapter.isAvailableSync)
783
+ return true;
784
+ try {
785
+ return adapter.isAvailableSync();
786
+ }
787
+ catch {
788
+ return false;
789
+ }
790
+ }
754
791
  // All sync-capable adapters (initialized set, or the registry before init).
755
792
  syncCapableAdapters() {
756
793
  const source = this.adapters.size > 0 ? this.adapters.values() : this.registry.getAll().values();
@@ -863,13 +900,43 @@ export class Strata {
863
900
  const attach = () => {
864
901
  if (cancelled)
865
902
  return;
866
- const targets = options?.storage !== undefined
903
+ const explicit = options?.storage !== undefined;
904
+ const targets = explicit
867
905
  ? [this.adapters.get(options.storage)]
868
906
  : Array.from(this.adapters.values());
869
907
  for (const adapter of targets) {
870
- if (adapter?.subscribe) {
908
+ if (!adapter?.subscribe)
909
+ continue;
910
+ // 🔴 Skip backends that cannot observe rather than letting the first one
911
+ // abort the fan-out. `BaseAdapter.subscribe` throws NotSupportedError
912
+ // when `capabilities.observable` is false — true for indexedDB, cookies
913
+ // and cache, ALL of which the default registration includes — so the
914
+ // documented options-less "hear every adapter" form threw on every
915
+ // default instance and took application boot down with it.
916
+ //
917
+ // An observer that hears fewer backends is the correct outcome of "hear
918
+ // every adapter" when some cannot speak. When the caller named ONE
919
+ // backend explicitly they get told, because silence is not what they
920
+ // asked for.
921
+ if (!adapter.capabilities.observable) {
922
+ if (explicit) {
923
+ logger.warn(`subscribe: storage "${adapter.name}" does not support change events, so this ` +
924
+ `subscription will never fire. Target an observable backend ` +
925
+ `(memory, localStorage, sessionStorage, url).`);
926
+ }
927
+ else {
928
+ logger.debug(`subscribe: skipping non-observable adapter "${adapter.name}".`);
929
+ }
930
+ continue;
931
+ }
932
+ // A custom adapter may still refuse despite advertising the capability;
933
+ // one that does must not cost the caller every other subscription.
934
+ try {
871
935
  unsubscribers.push(adapter.subscribe(effectiveCallback));
872
936
  }
937
+ catch (error) {
938
+ logger.debug(`subscribe: adapter "${adapter.name}" refused to attach:`, error);
939
+ }
873
940
  }
874
941
  };
875
942
  // Attach now if ready; otherwise once initialization completes — so a
@@ -1231,6 +1298,70 @@ export class Strata {
1231
1298
  */
1232
1299
  registerAdapter(adapter) {
1233
1300
  this.registry.register(adapter);
1301
+ // 🔴 Apply the adapter's config NOW, not at initialize(). The synchronous
1302
+ // API is usable before initialization completes (selectAdapterSync falls
1303
+ // back to the registry), so a `prefix` applied only in the async
1304
+ // initialize() is silently absent for every setSync/getSync issued in that
1305
+ // window and the value lands at the bare key. That was ISSUE-08: the
1306
+ // adapter honoured `initialize({ prefix })` all along, and the config path
1307
+ // to it did not reach the sync path.
1308
+ this.applyAdapterConfig(adapter);
1309
+ }
1310
+ /**
1311
+ * Web adapters that share their storage area with every other script on the
1312
+ * origin, and therefore take the instance-wide `keyPrefix`.
1313
+ *
1314
+ * Cookies are excluded deliberately: they already default to `strata_`, so
1315
+ * moving them would break existing cookies for no gain. IndexedDB and the Cache
1316
+ * API own a named store, memory owns its own Map, and the URL adapter already
1317
+ * prefixes its params — none of them can collide with another script's keys.
1318
+ */
1319
+ static PREFIXED_WEB_ADAPTERS = new Set([
1320
+ 'localStorage',
1321
+ 'sessionStorage',
1322
+ ]);
1323
+ /**
1324
+ * The configured options for one adapter, or undefined when it has none.
1325
+ *
1326
+ * For the shared-area web adapters this also resolves the 3.0.0 key prefix and
1327
+ * decides whether that adapter may adopt pre-3.0 unprefixed entries. Precedence,
1328
+ * and it lives only here:
1329
+ *
1330
+ * 1. `adapters.<name>.prefix` — the most specific thing the caller wrote
1331
+ * 2. `keyPrefix` — the instance-wide switch (`false` restores 2.x behaviour)
1332
+ * 3. the adapter's own constructor default (`DEFAULT_WEB_KEY_PREFIX`)
1333
+ */
1334
+ adapterConfigFor(name) {
1335
+ const raw = this.config.adapters?.[name];
1336
+ const explicit = typeof raw === 'object' && raw !== null ? { ...raw } : undefined;
1337
+ if (!Strata.PREFIXED_WEB_ADAPTERS.has(name))
1338
+ return explicit;
1339
+ const resolved = explicit ?? {};
1340
+ // Only fill in the prefix when the caller did not name one for this adapter.
1341
+ if (resolved.prefix === undefined && this.config.keyPrefix !== undefined) {
1342
+ resolved.prefix = this.config.keyPrefix === false ? '' : this.config.keyPrefix;
1343
+ }
1344
+ // 🔴 Migration is enabled HERE and nowhere else — only for an adapter whose
1345
+ // prefix this instance resolved. A directly constructed adapter must never
1346
+ // adopt bare keys: `plugin/web.ts` builds a `strata_prefs_` instance beside
1347
+ // the main one, and if that adopted every unprefixed entry it found it would
1348
+ // take them from the instance they belong to.
1349
+ if (resolved.migrateLegacyKeys === undefined) {
1350
+ resolved.migrateLegacyKeys = this.config.migrateLegacyKeys !== false;
1351
+ }
1352
+ return resolved;
1353
+ }
1354
+ /** Push the configured options into an adapter synchronously. */
1355
+ applyAdapterConfig(adapter) {
1356
+ const config = this.adapterConfigFor(adapter.name);
1357
+ if (!config || !adapter.configure)
1358
+ return;
1359
+ try {
1360
+ adapter.configure(config);
1361
+ }
1362
+ catch (error) {
1363
+ logger.warn(`Failed to configure ${adapter.name} adapter:`, error);
1364
+ }
1234
1365
  }
1235
1366
  /**
1236
1367
  * Initialize and attach any registered adapters that are not yet active, then
@@ -1397,8 +1528,7 @@ export class Strata {
1397
1528
  try {
1398
1529
  if (!(await adapter.isAvailable()))
1399
1530
  continue;
1400
- const adapterConfig = typeof rawConfig === 'object' ? rawConfig : undefined;
1401
- await adapter.initialize(adapterConfig);
1531
+ await adapter.initialize(this.adapterConfigFor(name));
1402
1532
  this.adapters.set(name, adapter);
1403
1533
  }
1404
1534
  catch (error) {
@@ -1590,6 +1720,7 @@ export class Strata {
1590
1720
  }
1591
1721
  })();
1592
1722
  }, cfg.interval);
1723
+ this._autoBackupTimer.unref?.();
1593
1724
  }
1594
1725
  async selectAdapter(storage) {
1595
1726
  await this.ensureReady();
@@ -1609,7 +1740,9 @@ export class Strata {
1609
1740
  for (const s of storages) {
1610
1741
  const adapter = this.registry.get(s);
1611
1742
  if (adapter && (await adapter.isAvailable())) {
1612
- await adapter.initialize();
1743
+ // Pass the adapter's configured options — initializing bare here dropped
1744
+ // the prefix for any adapter first reached through this path.
1745
+ await adapter.initialize(this.adapterConfigFor(s));
1613
1746
  this.adapters.set(s, adapter);
1614
1747
  return adapter;
1615
1748
  }
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export { CookieAdapter } from "./adapters/web/CookieAdapter.js";
8
8
  export { CacheAdapter } from "./adapters/web/CacheAdapter.js";
9
9
  export { MemoryAdapter } from "./adapters/web/MemoryAdapter.js";
10
10
  export { URLAdapter, type URLAdapterConfig } from "./adapters/web/URLAdapter.js";
11
+ export { DEFAULT_WEB_KEY_PREFIX } from "./adapters/web/LocalStorageAdapter.js";
11
12
  export { EncryptionManager } from "./features/encryption.js";
12
13
  export { CompressionManager } from "./features/compression.js";
13
14
  export { TTLManager } from "./features/ttl.js";
@@ -19,7 +20,8 @@ export { MigrationManager } from "./features/migration.js";
19
20
  export type { Migration } from "./features/migration.js";
20
21
  export { StrataError, StorageError, IntegrityError, QuotaExceededError, EncryptionError, CompressionError, SerializationError, ValidationError, NotSupportedError, AdapterNotAvailableError, } from "./utils/errors.js";
21
22
  export type { StorageType, StorageOptions, StorageValue, StorageAdapter, AdapterConfig, QueryOptions, SyncConfig, EncryptionConfig, CompressionConfig, ObserverCallback, StorageEvent, StorageCapabilities, StorageMetadata, TTLConfig, StrataConfig, StorageChange, SubscriptionCallback, UnsubscribeFunction, } from "./types/index.js";
22
- export { isValidKey, isValidValue, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
23
+ export { setLogLevel, getLogLevel, type LogLevel } from "./utils/logger.js";
24
+ export { isValidKey, isValidValue, isStorageEnvelope, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
23
25
  import { Strata } from "./core/Strata.js";
24
26
  import type { StrataConfig } from "./types/index.js";
25
27
  /**
@@ -28,7 +30,7 @@ import type { StrataConfig } from "./types/index.js";
28
30
  * custom instance can opt into the same default set. Returns the same instance
29
31
  * for chaining.
30
32
  */
31
- export declare function registerWebAdapters(strata: Strata): Strata;
33
+ export declare function registerWebAdapters(strata: Strata, config?: StrataConfig): Strata;
32
34
  /**
33
35
  * Create a ready-to-use Strata instance with the standard web adapters
34
36
  * pre-registered — the framework-agnostic, Zustand-style entry point. Create it
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ export { CookieAdapter } from "./adapters/web/CookieAdapter.js";
10
10
  export { CacheAdapter } from "./adapters/web/CacheAdapter.js";
11
11
  export { MemoryAdapter } from "./adapters/web/MemoryAdapter.js";
12
12
  export { URLAdapter } from "./adapters/web/URLAdapter.js";
13
+ export { DEFAULT_WEB_KEY_PREFIX } from "./adapters/web/LocalStorageAdapter.js";
13
14
  // Core features
14
15
  export { EncryptionManager } from "./features/encryption.js";
15
16
  export { CompressionManager } from "./features/compression.js";
@@ -23,8 +24,17 @@ export { computeChecksum, verifyChecksum } from "./features/integrity.js";
23
24
  export { MigrationManager } from "./features/migration.js";
24
25
  // Error classes (exported as values so consumers can use `instanceof`)
25
26
  export { StrataError, StorageError, IntegrityError, QuotaExceededError, EncryptionError, CompressionError, SerializationError, ValidationError, NotSupportedError, AdapterNotAvailableError, } from "./utils/errors.js";
27
+ // Diagnostics.
28
+ //
29
+ // 🔴 The logger's own documentation names `setLogLevel('debug')` as the way to
30
+ // raise verbosity, and it was never exported from this entry point — so the
31
+ // documented control was unreachable. That matters as of 2.9.0: keys this
32
+ // library does not own are now SKIPPED and reported at `debug`, which is below
33
+ // the default `warn`. Raising the level is how a consumer answers "why is my
34
+ // key missing from keys()?", so the control has to be reachable.
35
+ export { setLogLevel, getLogLevel } from "./utils/logger.js";
26
36
  // Utils
27
- export { isValidKey, isValidValue, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
37
+ export { isValidKey, isValidValue, isStorageEnvelope, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
28
38
  // Create and export a default storage instance that works immediately
29
39
  import { Strata } from "./core/Strata.js";
30
40
  import { LocalStorageAdapter } from "./adapters/web/LocalStorageAdapter.js";
@@ -40,14 +50,29 @@ import { logger } from "./utils/logger.js";
40
50
  * custom instance can opt into the same default set. Returns the same instance
41
51
  * for chaining.
42
52
  */
43
- export function registerWebAdapters(strata) {
53
+ export function registerWebAdapters(strata, config) {
54
+ // `adapters: { <name>: false }` opts an adapter out of REGISTRATION, not just
55
+ // out of initialization. Registering one the instance will never use still
56
+ // costs a TTL sweep over a storage area it does not own.
57
+ //
58
+ // 🔴 `defaultStorages` is NOT this switch. It is the preference order for
59
+ // picking the DEFAULT adapter; multi-adapter operations (keys/clear/size/
60
+ // subscribe with no `storage`) deliberately span everything registered. That
61
+ // distinction was undocumented, and reading `defaultStorages` as a
62
+ // registration allow-list is what produced ISSUE-09.
63
+ const enabled = (name) => config?.adapters?.[name] !== false;
44
64
  try {
45
65
  strata.registerAdapter(new MemoryAdapter()); // always-available fallback
46
- strata.registerAdapter(new LocalStorageAdapter());
47
- strata.registerAdapter(new SessionStorageAdapter());
48
- strata.registerAdapter(new IndexedDBAdapter());
49
- strata.registerAdapter(new CookieAdapter());
50
- strata.registerAdapter(new CacheAdapter());
66
+ if (enabled('localStorage'))
67
+ strata.registerAdapter(new LocalStorageAdapter());
68
+ if (enabled('sessionStorage'))
69
+ strata.registerAdapter(new SessionStorageAdapter());
70
+ if (enabled('indexedDB'))
71
+ strata.registerAdapter(new IndexedDBAdapter());
72
+ if (enabled('cookies'))
73
+ strata.registerAdapter(new CookieAdapter());
74
+ if (enabled('cache'))
75
+ strata.registerAdapter(new CacheAdapter());
51
76
  }
52
77
  catch (error) {
53
78
  logger.warn('Strata Storage adapter registration warning:', error);
@@ -71,7 +96,7 @@ export function registerWebAdapters(strata) {
71
96
  * ```
72
97
  */
73
98
  export function defineStorage(config) {
74
- return registerWebAdapters(new Strata(config));
99
+ return registerWebAdapters(new Strata(config), config);
75
100
  }
76
101
  // Default singleton — created via the same factory so behavior is identical.
77
102
  // It initializes lazily on first use, so importing the package has no I/O cost.
@@ -308,6 +308,38 @@ export interface StrataConfig {
308
308
  * Default storage types in order of preference
309
309
  */
310
310
  defaultStorages?: StorageType[];
311
+ /**
312
+ * Key prefix for the web adapters that share a storage area with every other
313
+ * script on the origin (`localStorage`, `sessionStorage`).
314
+ *
315
+ * Defaults to `'strata:'` as of 3.0.0. Set `false` (or `''`) for the pre-3.0
316
+ * behaviour of writing to the bare key.
317
+ *
318
+ * 🔴 **Take the opt-out when anything outside this library reads a physical key
319
+ * directly** — a pre-paint theme script that runs before any module loads, or a
320
+ * logger reading its own level. Those readers know the exact key name, and a
321
+ * prefix changes it underneath them. `migrateLegacyKeys` keeps the *data*
322
+ * reachable through this library, but it cannot fix a hard-coded reader.
323
+ *
324
+ * Composes with `namespace`, which is a separate mechanism and unaffected:
325
+ * the physical key is `<keyPrefix><namespace>:<key>`. A per-adapter
326
+ * `adapters.localStorage.prefix` overrides this for that adapter.
327
+ */
328
+ keyPrefix?: string | false;
329
+ /**
330
+ * Whether the shared-area web adapters may adopt pre-3.0 unprefixed entries.
331
+ * Default `true`.
332
+ *
333
+ * Migration is per key and happens on read: a miss at the prefixed key falls
334
+ * back to the bare key, and if the value is one of ours it is moved under the
335
+ * prefix. It never overwrites an existing prefixed value, and it never adopts a
336
+ * value that is not a `StorageValue` envelope — which is what stops it taking
337
+ * another application's keys.
338
+ *
339
+ * Set `false` when two applications share an origin and one of them is still on
340
+ * 2.x, so that upgrading one does not move keys the other still reads.
341
+ */
342
+ migrateLegacyKeys?: boolean;
311
343
  /**
312
344
  * Adapter configuration
313
345
  */
@@ -518,6 +550,19 @@ export interface StorageAdapter {
518
550
  * Get all keys
519
551
  */
520
552
  keys(pattern?: string | RegExp): Promise<string[]>;
553
+ /**
554
+ * Apply configuration synchronously, before any operation runs. Optional —
555
+ * an adapter with no configurable fields omits it. Implementations do pure
556
+ * assignment only: config that changes the physical key cannot wait for the
557
+ * async `initialize()`, because the synchronous API is usable before it.
558
+ */
559
+ configure?(config?: unknown): void;
560
+ /**
561
+ * Whether this backend can serve a SYNCHRONOUS operation right now, without
562
+ * awaiting anything. Optional; absence means "assume usable". Lets the sync
563
+ * path honour `defaultStorages` as the ordered fallback list it reads as.
564
+ */
565
+ isAvailableSync?(): boolean;
521
566
  /** Synchronous get — sync-capable adapters only. */
522
567
  getSync?<T = unknown>(key: string): StorageValue<T> | null;
523
568
  /** Synchronous set — sync-capable adapters only. */
@@ -2,6 +2,7 @@
2
2
  * Utility functions for Strata Storage
3
3
  * Zero dependencies - all utilities implemented from scratch
4
4
  */
5
+ import type { StorageValue } from "../types/index.js";
5
6
  /**
6
7
  * Check if code is running in a browser environment
7
8
  */
@@ -134,6 +135,24 @@ export declare function serialize(value: unknown): string;
134
135
  * pathological patterns (see SAFE_REGEX_MAX_LENGTH).
135
136
  */
136
137
  export declare function deserialize(json: string): unknown;
138
+ /**
139
+ * Whether a deserialized value is a StorageValue envelope this library wrote.
140
+ *
141
+ * 🔴 This is the definition of "a key this adapter owns", and it is the ONLY
142
+ * thing that can answer that question when the key prefix is empty. Web
143
+ * adapters share their storage area with every other script on the origin, and
144
+ * an empty prefix makes `startsWith(prefix)` true for every key there — so name
145
+ * alone cannot distinguish our data from a third-party script's. Shape can.
146
+ *
147
+ * A value failing this check is not an error: it is evidence the key belongs to
148
+ * somebody else, which is the ordinary case in a shared area. Callers skip it
149
+ * silently rather than reporting a failure about data they do not own.
150
+ *
151
+ * Deliberately strict — `created` and `updated` are written by every write path
152
+ * (`Strata.set`, `setSync`, import, restore) and are finite numbers, so a
153
+ * foreign object that merely happens to carry a `value` key does not qualify.
154
+ */
155
+ export declare function isStorageEnvelope(value: unknown): value is StorageValue;
137
156
  /**
138
157
  * Calculate object size in bytes (rough estimate)
139
158
  */
@@ -334,6 +334,35 @@ export function deserialize(json) {
334
334
  return val;
335
335
  });
336
336
  }
337
+ /**
338
+ * Whether a deserialized value is a StorageValue envelope this library wrote.
339
+ *
340
+ * 🔴 This is the definition of "a key this adapter owns", and it is the ONLY
341
+ * thing that can answer that question when the key prefix is empty. Web
342
+ * adapters share their storage area with every other script on the origin, and
343
+ * an empty prefix makes `startsWith(prefix)` true for every key there — so name
344
+ * alone cannot distinguish our data from a third-party script's. Shape can.
345
+ *
346
+ * A value failing this check is not an error: it is evidence the key belongs to
347
+ * somebody else, which is the ordinary case in a shared area. Callers skip it
348
+ * silently rather than reporting a failure about data they do not own.
349
+ *
350
+ * Deliberately strict — `created` and `updated` are written by every write path
351
+ * (`Strata.set`, `setSync`, import, restore) and are finite numbers, so a
352
+ * foreign object that merely happens to carry a `value` key does not qualify.
353
+ */
354
+ export function isStorageEnvelope(value) {
355
+ if (!isObject(value))
356
+ return false;
357
+ if (!('value' in value))
358
+ return false;
359
+ if (!Number.isFinite(value.created) || !Number.isFinite(value.updated))
360
+ return false;
361
+ if ('expires' in value && value.expires !== undefined && !Number.isFinite(value.expires)) {
362
+ return false;
363
+ }
364
+ return true;
365
+ }
337
366
  /**
338
367
  * Calculate object size in bytes (rough estimate)
339
368
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strata-storage",
3
- "version": "2.8.5",
3
+ "version": "3.0.0",
4
4
  "description": "One storage API across web, iOS and Android — zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "packageManager": "yarn@4.17.0",
package/scripts/build.js CHANGED
@@ -153,5 +153,98 @@ fs.writeFileSync(
153
153
  JSON.stringify({ type: 'module', sideEffects: false }, null, 2) + '\n'
154
154
  );
155
155
 
156
+ // ---------------------------------------------------------------------------
157
+ // Build gates
158
+ //
159
+ // This project deliberately ships no test framework — typecheck, lint and build
160
+ // are the gates (README ▸ Limitations). These two checks live inside the build
161
+ // for that reason: they add no dependency and no runner, and they cover exactly
162
+ // the two things the other gates are blind to.
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const fail = (message) => {
166
+ console.error(`\n❌ ${message}`);
167
+ process.exit(1);
168
+ };
169
+
170
+ // GATE 1 — the storage-ownership predicate.
171
+ //
172
+ // `isStorageEnvelope` decides whether a key in a SHARED storage area belongs to
173
+ // this library. Get it wrong in the permissive direction and we are back to
174
+ // reading, error-logging about, and deleting other applications' data (ISSUE-01,
175
+ // ISSUE-09); wrong in the strict direction and we stop recognising our own.
176
+ // Neither failure is visible to typecheck, lint or a green build.
177
+ console.log('🔍 Gate: storage-ownership predicate...');
178
+ const { isStorageEnvelope } = await import(
179
+ new URL('../dist/utils/index.js', import.meta.url).href
180
+ );
181
+
182
+ const envelope = (extra = {}) => ({ value: 'v', created: 1, updated: 2, ...extra });
183
+ const OWNERSHIP_CASES = [
184
+ // Foreign values seen in the wild — every one of these reached logger.error
185
+ // before 2.9.0. `ts3hsf` is Microsoft Clarity's `_cltk`; `warn` is a consumer
186
+ // logger's own level key.
187
+ ['clarity _cltk raw string', 'ts3hsf', false],
188
+ ['logger-level raw string', 'warn', false],
189
+ ['foreign JSON object', { a: 1 }, false],
190
+ ['foreign JSON array', [1, 2, 3], false],
191
+ ['foreign object carrying a value key', { value: 'v' }, false],
192
+ ['null', null, false],
193
+ ['undefined', undefined, false],
194
+ ['number', 42, false],
195
+ ['string', 'plain', false],
196
+ // Near-misses: shaped like ours but not written by us.
197
+ ['envelope missing created', { value: 'v', updated: 2 }, false],
198
+ ['envelope with non-numeric updated', { value: 'v', created: 1, updated: 'x' }, false],
199
+ ['envelope with NaN created', { value: 'v', created: NaN, updated: 2 }, false],
200
+ ['envelope with non-numeric expires', envelope({ expires: 'soon' }), false],
201
+ // Ours, in every shape a write path produces.
202
+ ['plain envelope', envelope(), true],
203
+ ['envelope holding null', envelope({ value: null }), true],
204
+ ['envelope with expires', envelope({ expires: Date.now() }), true],
205
+ ['envelope with tags + metadata', envelope({ tags: ['a'], metadata: { b: 1 } }), true],
206
+ ['envelope with created 0', { value: 'v', created: 0, updated: 0 }, true],
207
+ ];
208
+
209
+ const ownershipFailures = OWNERSHIP_CASES.filter(
210
+ ([, input, expected]) => isStorageEnvelope(input) !== expected
211
+ ).map(([name, , expected]) => ` · ${name}: expected ${expected}, got ${!expected}`);
212
+
213
+ if (ownershipFailures.length > 0) {
214
+ fail(
215
+ `Storage-ownership predicate is wrong in ${ownershipFailures.length} case(s):\n` +
216
+ ownershipFailures.join('\n')
217
+ );
218
+ }
219
+ console.log(` ✓ ${OWNERSHIP_CASES.length} ownership cases`);
220
+
221
+ // GATE 2 — the README's version row cannot drift from package.json.
222
+ //
223
+ // The row is a hand-maintained duplicate of `version`, so it goes stale the
224
+ // moment the version is bumped — it has already shipped stale once (2.8.5), and
225
+ // three in-repo files disagreeing about the current version is ISSUE-06. This
226
+ // asserts on the ARTEFACT that npm actually renders, not on the release process
227
+ // that is supposed to update it.
228
+ console.log('🔍 Gate: README version row matches package.json...');
229
+ const pkgVersion = JSON.parse(
230
+ fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
231
+ ).version;
232
+ const readme = fs.readFileSync(path.join(rootDir, 'README.md'), 'utf8');
233
+ const versionRow = readme.match(/^\|\s*\*\*Version\*\*\s*\|\s*`([^`]+)`/m);
234
+
235
+ if (!versionRow) {
236
+ fail(
237
+ 'README has no at-a-glance `| **Version** | `x.y.z` |` row, so nothing pins the ' +
238
+ 'published version claim. Restore the row rather than removing this gate.'
239
+ );
240
+ }
241
+ if (versionRow[1] !== pkgVersion) {
242
+ fail(
243
+ `README version row says \`${versionRow[1]}\` but package.json says \`${pkgVersion}\`. ` +
244
+ 'Update the README row — npm renders it verbatim on the package page.'
245
+ );
246
+ }
247
+ console.log(` ✓ README version row is ${pkgVersion}`);
248
+
156
249
  console.log('✅ Build completed successfully!');
157
250
  console.log(`📂 Output: ${distDir}`);