strata-storage 2.8.4 โ†’ 2.9.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.
@@ -39,7 +39,7 @@ export class URLAdapter extends BaseAdapter {
39
39
  typeof window.location !== 'undefined' &&
40
40
  typeof window.history !== 'undefined');
41
41
  }
42
- async initialize(config) {
42
+ configure(config) {
43
43
  if (config?.mode)
44
44
  this.mode = config.mode;
45
45
  if (config?.prefix !== undefined)
@@ -48,6 +48,13 @@ export class URLAdapter extends BaseAdapter {
48
48
  this.historyMode = config.history;
49
49
  if (typeof config?.maxLength === 'number')
50
50
  this.maxLength = config.maxLength;
51
+ }
52
+ /** The URL is usable whenever `window.location` is. */
53
+ isAvailableSync() {
54
+ return typeof window !== 'undefined' && typeof window.location !== 'undefined';
55
+ }
56
+ async initialize(config) {
57
+ this.configure(config);
51
58
  if (typeof window === 'undefined')
52
59
  return;
53
60
  // Seed the snapshot and listen for external navigation (back/forward, manual
@@ -14,6 +14,22 @@ export declare abstract class BaseAdapter implements StorageAdapter {
14
14
  protected queryEngine: QueryEngine;
15
15
  protected ttlCleanupInterval?: ReturnType<typeof setInterval>;
16
16
  protected ttlCheckInterval: number;
17
+ /**
18
+ * Apply adapter configuration SYNCHRONOUSLY, before any operation runs.
19
+ *
20
+ * ๐Ÿ”ด Config that changes the PHYSICAL KEY (`prefix`) cannot wait for the async
21
+ * `initialize()`. The synchronous API is deliberately usable before
22
+ * initialization completes โ€” `Strata.selectAdapterSync()` falls back to the
23
+ * registry โ€” so a `prefix` applied only at `initialize()` is silently absent
24
+ * for every `setSync`/`getSync` issued in that window, and the value lands at
25
+ * the bare key. That is the whole of ISSUE-08: `initialize({ prefix })` worked,
26
+ * and the config path to it did not.
27
+ *
28
+ * `initialize()` calls this first, so both paths agree. Default is a no-op;
29
+ * adapters with configurable fields override it with pure assignment only โ€”
30
+ * never I/O, never a timer.
31
+ */
32
+ configure(_config?: unknown): void;
17
33
  /**
18
34
  * Initialize TTL cleanup if needed
19
35
  */
@@ -33,6 +49,22 @@ export declare abstract class BaseAdapter implements StorageAdapter {
33
49
  * `Strata.cleanupExpired()`.
34
50
  */
35
51
  cleanupExpired(): Promise<number>;
52
+ /**
53
+ * Parse a raw stored string into a value THIS adapter owns, or null.
54
+ *
55
+ * ๐Ÿ”ด The classification is the point, not the parse. Web adapters share their
56
+ * storage area with every other script on the origin, so a raw value that is
57
+ * not our envelope means the key belongs to somebody else โ€” the ordinary case,
58
+ * not a failure. Reporting it through `logger.error` asserts something untrue
59
+ * about the consumer's own data, and on any origin running a third-party
60
+ * script that stores a raw string (Microsoft Clarity's `_cltk` is the usual
61
+ * one) it produces a permanent error stream that reaches their error tracker.
62
+ *
63
+ * `logger.error` is therefore reserved for a key carrying OUR envelope that
64
+ * still fails โ€” which is a real defect, and currently cannot be distinguished
65
+ * from somebody else's data at all.
66
+ */
67
+ protected parseOwnValue<T = unknown>(raw: string | null, key: string): StorageValue<T> | null;
36
68
  /**
37
69
  * Check if value is expired
38
70
  */
@@ -2,7 +2,7 @@
2
2
  * Base adapter implementation with common functionality
3
3
  */
4
4
  import { NotSupportedError } from "../utils/errors.js";
5
- import { EventEmitter, matchGlob, getObjectSize } from "../utils/index.js";
5
+ import { EventEmitter, matchGlob, getObjectSize, deserialize, isStorageEnvelope } from "../utils/index.js";
6
6
  import { logger } from "../utils/logger.js";
7
7
  import { QueryEngine } from "../features/query.js";
8
8
  /**
@@ -13,6 +13,24 @@ export class BaseAdapter {
13
13
  queryEngine = new QueryEngine();
14
14
  ttlCleanupInterval;
15
15
  ttlCheckInterval = 60000; // Check every minute
16
+ /**
17
+ * Apply adapter configuration SYNCHRONOUSLY, before any operation runs.
18
+ *
19
+ * ๐Ÿ”ด Config that changes the PHYSICAL KEY (`prefix`) cannot wait for the async
20
+ * `initialize()`. The synchronous API is deliberately usable before
21
+ * initialization completes โ€” `Strata.selectAdapterSync()` falls back to the
22
+ * registry โ€” so a `prefix` applied only at `initialize()` is silently absent
23
+ * for every `setSync`/`getSync` issued in that window, and the value lands at
24
+ * the bare key. That is the whole of ISSUE-08: `initialize({ prefix })` worked,
25
+ * and the config path to it did not.
26
+ *
27
+ * `initialize()` calls this first, so both paths agree. Default is a no-op;
28
+ * adapters with configurable fields override it with pure assignment only โ€”
29
+ * never I/O, never a timer.
30
+ */
31
+ configure(_config) {
32
+ // No configurable fields by default.
33
+ }
16
34
  /**
17
35
  * Initialize TTL cleanup if needed
18
36
  */
@@ -60,6 +78,40 @@ export class BaseAdapter {
60
78
  }
61
79
  return removed;
62
80
  }
81
+ /**
82
+ * Parse a raw stored string into a value THIS adapter owns, or null.
83
+ *
84
+ * ๐Ÿ”ด The classification is the point, not the parse. Web adapters share their
85
+ * storage area with every other script on the origin, so a raw value that is
86
+ * not our envelope means the key belongs to somebody else โ€” the ordinary case,
87
+ * not a failure. Reporting it through `logger.error` asserts something untrue
88
+ * about the consumer's own data, and on any origin running a third-party
89
+ * script that stores a raw string (Microsoft Clarity's `_cltk` is the usual
90
+ * one) it produces a permanent error stream that reaches their error tracker.
91
+ *
92
+ * `logger.error` is therefore reserved for a key carrying OUR envelope that
93
+ * still fails โ€” which is a real defect, and currently cannot be distinguished
94
+ * from somebody else's data at all.
95
+ */
96
+ parseOwnValue(raw, key) {
97
+ if (raw === null || raw === '')
98
+ return null;
99
+ let parsed;
100
+ try {
101
+ parsed = deserialize(raw);
102
+ }
103
+ catch {
104
+ logger.debug(`${this.name}: skipping key "${key}" โ€” value is not readable as ${this.name} data ` +
105
+ `(not written by this adapter).`);
106
+ return null;
107
+ }
108
+ if (!isStorageEnvelope(parsed)) {
109
+ logger.debug(`${this.name}: skipping key "${key}" โ€” value carries no storage envelope ` +
110
+ `(not written by this adapter).`);
111
+ return null;
112
+ }
113
+ return parsed;
114
+ }
63
115
  /**
64
116
  * Check if value is expired
65
117
  */
@@ -222,6 +222,12 @@ export declare class Strata {
222
222
  clearSync(options?: ClearOptions & StorageOptions): void;
223
223
  private requireSyncAdapter;
224
224
  private selectAdapterSync;
225
+ /**
226
+ * Whether an adapter can serve a synchronous operation right now. An adapter
227
+ * with no `isAvailableSync` is assumed usable โ€” absence of a probe is not
228
+ * evidence of unavailability.
229
+ */
230
+ private static isUsableSync;
225
231
  private syncCapableAdapters;
226
232
  private computeExpiration;
227
233
  /**
@@ -314,6 +320,10 @@ export declare class Strata {
314
320
  * ```
315
321
  */
316
322
  registerAdapter(adapter: StorageAdapter): void;
323
+ /** The configured options for one adapter, or undefined when it has none. */
324
+ private adapterConfigFor;
325
+ /** Push the configured options into an adapter synchronously. */
326
+ private applyAdapterConfig;
317
327
  /**
318
328
  * Initialize and attach any registered adapters that are not yet active, then
319
329
  * re-pick the default adapter. Call this after `registerAdapter()` on an
@@ -729,6 +729,8 @@ export class Strata {
729
729
  // Synchronous adapter lookup โ€” falls back to the registry so sync operations
730
730
  // work even before async initialize() has completed.
731
731
  selectAdapterSync(storage) {
732
+ // An explicitly named storage is honoured as asked โ€” the caller chose it, and
733
+ // a hard error naming it is more useful than a silent substitution.
732
734
  if (storage) {
733
735
  const names = Array.isArray(storage) ? storage : [storage];
734
736
  for (const name of names) {
@@ -738,19 +740,51 @@ export class Strata {
738
740
  }
739
741
  throw new StorageError(`No adapter registered for storage type(s): ${names.join(', ')}`);
740
742
  }
741
- if (this.defaultAdapter)
743
+ if (this.defaultAdapter && Strata.isUsableSync(this.defaultAdapter)) {
742
744
  return this.defaultAdapter;
745
+ }
746
+ // ๐Ÿ”ด `defaultStorages` reads as an ordered fallback list, so honour it as
747
+ // one HERE too. It previously guarded only the async path: with
748
+ // localStorage unavailable (SSR, private mode, a blocked cookie policy),
749
+ // `defineStorage({ defaultStorages: ['localStorage','memory'] }).setSync()`
750
+ // still selected localStorage and threw SerializationError rather than
751
+ // falling through to memory, which is the whole point of listing memory.
743
752
  const preferred = this.config.defaultStorages ?? [];
744
753
  for (const name of preferred) {
745
754
  const adapter = this.adapters.get(name) ?? this.registry.get(name);
746
- if (adapter)
755
+ if (adapter && Strata.isUsableSync(adapter))
756
+ return adapter;
757
+ }
758
+ for (const adapter of this.registry.getAll().values()) {
759
+ if (Strata.isUsableSync(adapter))
747
760
  return adapter;
748
761
  }
762
+ // Nothing usable: fall back to the previous behaviour so the caller gets the
763
+ // adapter's own specific error rather than a vague one from here.
764
+ if (this.defaultAdapter)
765
+ return this.defaultAdapter;
749
766
  const first = this.registry.getAll().values().next().value;
750
767
  if (first)
751
768
  return first;
752
769
  throw new StorageError('No storage adapter registered for synchronous operation.');
753
770
  }
771
+ /**
772
+ * Whether an adapter can serve a synchronous operation right now. An adapter
773
+ * with no `isAvailableSync` is assumed usable โ€” absence of a probe is not
774
+ * evidence of unavailability.
775
+ */
776
+ static isUsableSync(adapter) {
777
+ if (!adapter.capabilities.synchronous || !adapter.getSync)
778
+ return false;
779
+ if (!adapter.isAvailableSync)
780
+ return true;
781
+ try {
782
+ return adapter.isAvailableSync();
783
+ }
784
+ catch {
785
+ return false;
786
+ }
787
+ }
754
788
  // All sync-capable adapters (initialized set, or the registry before init).
755
789
  syncCapableAdapters() {
756
790
  const source = this.adapters.size > 0 ? this.adapters.values() : this.registry.getAll().values();
@@ -863,13 +897,43 @@ export class Strata {
863
897
  const attach = () => {
864
898
  if (cancelled)
865
899
  return;
866
- const targets = options?.storage !== undefined
900
+ const explicit = options?.storage !== undefined;
901
+ const targets = explicit
867
902
  ? [this.adapters.get(options.storage)]
868
903
  : Array.from(this.adapters.values());
869
904
  for (const adapter of targets) {
870
- if (adapter?.subscribe) {
905
+ if (!adapter?.subscribe)
906
+ continue;
907
+ // ๐Ÿ”ด Skip backends that cannot observe rather than letting the first one
908
+ // abort the fan-out. `BaseAdapter.subscribe` throws NotSupportedError
909
+ // when `capabilities.observable` is false โ€” true for indexedDB, cookies
910
+ // and cache, ALL of which the default registration includes โ€” so the
911
+ // documented options-less "hear every adapter" form threw on every
912
+ // default instance and took application boot down with it.
913
+ //
914
+ // An observer that hears fewer backends is the correct outcome of "hear
915
+ // every adapter" when some cannot speak. When the caller named ONE
916
+ // backend explicitly they get told, because silence is not what they
917
+ // asked for.
918
+ if (!adapter.capabilities.observable) {
919
+ if (explicit) {
920
+ logger.warn(`subscribe: storage "${adapter.name}" does not support change events, so this ` +
921
+ `subscription will never fire. Target an observable backend ` +
922
+ `(memory, localStorage, sessionStorage, url).`);
923
+ }
924
+ else {
925
+ logger.debug(`subscribe: skipping non-observable adapter "${adapter.name}".`);
926
+ }
927
+ continue;
928
+ }
929
+ // A custom adapter may still refuse despite advertising the capability;
930
+ // one that does must not cost the caller every other subscription.
931
+ try {
871
932
  unsubscribers.push(adapter.subscribe(effectiveCallback));
872
933
  }
934
+ catch (error) {
935
+ logger.debug(`subscribe: adapter "${adapter.name}" refused to attach:`, error);
936
+ }
873
937
  }
874
938
  };
875
939
  // Attach now if ready; otherwise once initialization completes โ€” so a
@@ -1231,6 +1295,31 @@ export class Strata {
1231
1295
  */
1232
1296
  registerAdapter(adapter) {
1233
1297
  this.registry.register(adapter);
1298
+ // ๐Ÿ”ด Apply the adapter's config NOW, not at initialize(). The synchronous
1299
+ // API is usable before initialization completes (selectAdapterSync falls
1300
+ // back to the registry), so a `prefix` applied only in the async
1301
+ // initialize() is silently absent for every setSync/getSync issued in that
1302
+ // window and the value lands at the bare key. That was ISSUE-08: the
1303
+ // adapter honoured `initialize({ prefix })` all along, and the config path
1304
+ // to it did not reach the sync path.
1305
+ this.applyAdapterConfig(adapter);
1306
+ }
1307
+ /** The configured options for one adapter, or undefined when it has none. */
1308
+ adapterConfigFor(name) {
1309
+ const raw = this.config.adapters?.[name];
1310
+ return typeof raw === 'object' && raw !== null ? raw : undefined;
1311
+ }
1312
+ /** Push the configured options into an adapter synchronously. */
1313
+ applyAdapterConfig(adapter) {
1314
+ const config = this.adapterConfigFor(adapter.name);
1315
+ if (!config || !adapter.configure)
1316
+ return;
1317
+ try {
1318
+ adapter.configure(config);
1319
+ }
1320
+ catch (error) {
1321
+ logger.warn(`Failed to configure ${adapter.name} adapter:`, error);
1322
+ }
1234
1323
  }
1235
1324
  /**
1236
1325
  * Initialize and attach any registered adapters that are not yet active, then
@@ -1397,8 +1486,7 @@ export class Strata {
1397
1486
  try {
1398
1487
  if (!(await adapter.isAvailable()))
1399
1488
  continue;
1400
- const adapterConfig = typeof rawConfig === 'object' ? rawConfig : undefined;
1401
- await adapter.initialize(adapterConfig);
1489
+ await adapter.initialize(this.adapterConfigFor(name));
1402
1490
  this.adapters.set(name, adapter);
1403
1491
  }
1404
1492
  catch (error) {
@@ -1609,7 +1697,9 @@ export class Strata {
1609
1697
  for (const s of storages) {
1610
1698
  const adapter = this.registry.get(s);
1611
1699
  if (adapter && (await adapter.isAvailable())) {
1612
- await adapter.initialize();
1700
+ // Pass the adapter's configured options โ€” initializing bare here dropped
1701
+ // the prefix for any adapter first reached through this path.
1702
+ await adapter.initialize(this.adapterConfigFor(s));
1613
1703
  this.adapters.set(s, adapter);
1614
1704
  return adapter;
1615
1705
  }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,8 @@ export { MigrationManager } from "./features/migration.js";
19
19
  export type { Migration } from "./features/migration.js";
20
20
  export { StrataError, StorageError, IntegrityError, QuotaExceededError, EncryptionError, CompressionError, SerializationError, ValidationError, NotSupportedError, AdapterNotAvailableError, } from "./utils/errors.js";
21
21
  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";
22
+ export { setLogLevel, getLogLevel, type LogLevel } from "./utils/logger.js";
23
+ export { isValidKey, isValidValue, isStorageEnvelope, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
23
24
  import { Strata } from "./core/Strata.js";
24
25
  import type { StrataConfig } from "./types/index.js";
25
26
  /**
@@ -28,7 +29,7 @@ import type { StrataConfig } from "./types/index.js";
28
29
  * custom instance can opt into the same default set. Returns the same instance
29
30
  * for chaining.
30
31
  */
31
- export declare function registerWebAdapters(strata: Strata): Strata;
32
+ export declare function registerWebAdapters(strata: Strata, config?: StrataConfig): Strata;
32
33
  /**
33
34
  * Create a ready-to-use Strata instance with the standard web adapters
34
35
  * pre-registered โ€” the framework-agnostic, Zustand-style entry point. Create it
package/dist/index.js CHANGED
@@ -23,8 +23,17 @@ export { computeChecksum, verifyChecksum } from "./features/integrity.js";
23
23
  export { MigrationManager } from "./features/migration.js";
24
24
  // Error classes (exported as values so consumers can use `instanceof`)
25
25
  export { StrataError, StorageError, IntegrityError, QuotaExceededError, EncryptionError, CompressionError, SerializationError, ValidationError, NotSupportedError, AdapterNotAvailableError, } from "./utils/errors.js";
26
+ // Diagnostics.
27
+ //
28
+ // ๐Ÿ”ด The logger's own documentation names `setLogLevel('debug')` as the way to
29
+ // raise verbosity, and it was never exported from this entry point โ€” so the
30
+ // documented control was unreachable. That matters as of 2.9.0: keys this
31
+ // library does not own are now SKIPPED and reported at `debug`, which is below
32
+ // the default `warn`. Raising the level is how a consumer answers "why is my
33
+ // key missing from keys()?", so the control has to be reachable.
34
+ export { setLogLevel, getLogLevel } from "./utils/logger.js";
26
35
  // Utils
27
- export { isValidKey, isValidValue, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
36
+ export { isValidKey, isValidValue, isStorageEnvelope, serializeValue, deserializeValue, generateId, createError, retry, debounce, throttle, } from "./utils/index.js";
28
37
  // Create and export a default storage instance that works immediately
29
38
  import { Strata } from "./core/Strata.js";
30
39
  import { LocalStorageAdapter } from "./adapters/web/LocalStorageAdapter.js";
@@ -40,14 +49,29 @@ import { logger } from "./utils/logger.js";
40
49
  * custom instance can opt into the same default set. Returns the same instance
41
50
  * for chaining.
42
51
  */
43
- export function registerWebAdapters(strata) {
52
+ export function registerWebAdapters(strata, config) {
53
+ // `adapters: { <name>: false }` opts an adapter out of REGISTRATION, not just
54
+ // out of initialization. Registering one the instance will never use still
55
+ // costs a TTL sweep over a storage area it does not own.
56
+ //
57
+ // ๐Ÿ”ด `defaultStorages` is NOT this switch. It is the preference order for
58
+ // picking the DEFAULT adapter; multi-adapter operations (keys/clear/size/
59
+ // subscribe with no `storage`) deliberately span everything registered. That
60
+ // distinction was undocumented, and reading `defaultStorages` as a
61
+ // registration allow-list is what produced ISSUE-09.
62
+ const enabled = (name) => config?.adapters?.[name] !== false;
44
63
  try {
45
64
  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());
65
+ if (enabled('localStorage'))
66
+ strata.registerAdapter(new LocalStorageAdapter());
67
+ if (enabled('sessionStorage'))
68
+ strata.registerAdapter(new SessionStorageAdapter());
69
+ if (enabled('indexedDB'))
70
+ strata.registerAdapter(new IndexedDBAdapter());
71
+ if (enabled('cookies'))
72
+ strata.registerAdapter(new CookieAdapter());
73
+ if (enabled('cache'))
74
+ strata.registerAdapter(new CacheAdapter());
51
75
  }
52
76
  catch (error) {
53
77
  logger.warn('Strata Storage adapter registration warning:', error);
@@ -71,7 +95,7 @@ export function registerWebAdapters(strata) {
71
95
  * ```
72
96
  */
73
97
  export function defineStorage(config) {
74
- return registerWebAdapters(new Strata(config));
98
+ return registerWebAdapters(new Strata(config), config);
75
99
  }
76
100
  // Default singleton โ€” created via the same factory so behavior is identical.
77
101
  // It initializes lazily on first use, so importing the package has no I/O cost.
@@ -518,6 +518,19 @@ export interface StorageAdapter {
518
518
  * Get all keys
519
519
  */
520
520
  keys(pattern?: string | RegExp): Promise<string[]>;
521
+ /**
522
+ * Apply configuration synchronously, before any operation runs. Optional โ€”
523
+ * an adapter with no configurable fields omits it. Implementations do pure
524
+ * assignment only: config that changes the physical key cannot wait for the
525
+ * async `initialize()`, because the synchronous API is usable before it.
526
+ */
527
+ configure?(config?: unknown): void;
528
+ /**
529
+ * Whether this backend can serve a SYNCHRONOUS operation right now, without
530
+ * awaiting anything. Optional; absence means "assume usable". Lets the sync
531
+ * path honour `defaultStorages` as the ordered fallback list it reads as.
532
+ */
533
+ isAvailableSync?(): boolean;
521
534
  /** Synchronous get โ€” sync-capable adapters only. */
522
535
  getSync?<T = unknown>(key: string): StorageValue<T> | null;
523
536
  /** 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.4",
3
+ "version": "2.9.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}`);