@jewel998/config 0.2.2 → 0.2.6

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.
@@ -1,78 +1,109 @@
1
- import type { CacheStorage, ConfigEventCallback, ConfigEventType } from "./types.js";
1
+ import { SdkError } from "./errors/index.js";
2
2
  import type { EvaluationContext } from "./plugins/types.js";
3
+ import type { CacheStorage } from "./types.js";
3
4
  export interface InitConfigOptions {
4
5
  /** Required. API key from the portal (cid_ for client, svr_ for server). */
5
6
  clientId: string;
6
7
  /**
7
- * Default/fallback values returned instantly before the API responds.
8
- * Keys are flag names, values are what your app uses immediately (optimistic).
8
+ * Tier 1 keys — fetched immediately, block ready() resolution.
9
+ * Use for values your app cannot render without.
10
+ * e.g. ["app.maintenance_mode", "feature.auth_provider"]
11
+ */
12
+ prefetch?: string[];
13
+ /**
14
+ * Default/fallback values returned instantly before any tier resolves.
15
+ * get() resolves immediately when a key has a default here.
9
16
  */
10
17
  defaults?: Record<string, unknown>;
11
- /** User context for targeting evaluation. Use autoContext() to auto-detect browser info. */
18
+ /** User context for targeting evaluation. Use autoContext() to auto-detect. */
12
19
  context?: EvaluationContext;
13
20
  /**
14
- * Your self-hosted API URL. Points to your Firebase deployment.
21
+ * Your self-hosted API URL.
15
22
  * Default: https://jewel998-config.web.app/api (demo instance)
16
- *
17
- * @example "https://your-project.web.app/api"
18
23
  */
19
24
  baseUrl?: string;
20
25
  /**
21
- * Cache storage adapter. Controls where resolved config values are persisted.
22
- *
23
- * - `memoryStorage()` (default) — In-memory, lost on page reload
24
- * - `browserStorage()` — localStorage, persists across page reloads and sessions
25
- *
26
- * @example
27
- * ```ts
28
- * import { browserStorage } from "@jewel998/config";
29
- * initConfig({ storage: browserStorage({ prefix: "myapp" }) })
30
- * ```
26
+ * Cache storage adapter. Default: `browserStorage()` (localStorage), which
27
+ * persists values and the config version across page loads so a reload or
28
+ * return visit skips the `/config` fetch when nothing changed. Falls back to
29
+ * in-memory automatically when `localStorage` is unavailable (SSR / Node /
30
+ * server keys). Pass `memoryStorage()` explicitly to opt out of persistence.
31
31
  */
32
32
  storage?: CacheStorage;
33
33
  /**
34
- * Polling interval in milliseconds for version checks.
35
- * The SDK periodically calls /api/version (lightweight ~100 bytes)
36
- * and only re-fetches full data when the version changes.
37
- *
38
- * Default: 300000 (5 minutes). Set to 0 to disable polling.
34
+ * Version polling interval in milliseconds.
35
+ * Default: 300_000 (5 minutes). Set to 0 to disable.
39
36
  */
40
37
  pollInterval?: number;
38
+ /**
39
+ * Global timeout in milliseconds for get() calls with no default.
40
+ * If a key does not arrive within this window, get() rejects with
41
+ * SdkError { type: "TIMEOUT" } and onError is called.
42
+ * Default: 30_000 (30 seconds).
43
+ */
44
+ timeout?: number;
45
+ /**
46
+ * Global error handler. Called on every SDK-level error:
47
+ * timeouts, fetch failures, auth errors, key-not-found, rate limits.
48
+ *
49
+ * get() also rejects with the same error — onError is for centralised
50
+ * logging/monitoring, not a replacement for catch().
51
+ */
52
+ onError?: (error: SdkError) => void;
41
53
  }
42
54
  export interface Flags {
43
- /** Get a flag value. Returns the default until the API responds, then the resolved value. */
44
- get<T = unknown>(key: string): T;
45
- /** Get a boolean flag. Returns false if the flag doesn't exist. */
46
- flag(key: string): boolean;
47
- /** Get all resolved flag values. */
48
- all(): Record<string, unknown>;
49
- /** Update user context. Triggers a re-fetch in client-key mode. */
55
+ /**
56
+ * Resolves when Tier 1 (init prefetch) keys have been fetched.
57
+ * Resolves instantly if no prefetch keys were declared.
58
+ */
59
+ ready(): Promise<void>;
60
+ /**
61
+ * Tier 2 fetch hint — fire-and-forget.
62
+ * Keys already tracked in Tier 1 or already fetched are skipped.
63
+ * Emits "updated" and "updated:key" events per key on completion.
64
+ */
65
+ prefetch(keys: string[]): void;
66
+ /**
67
+ * Get a typed config value asynchronously.
68
+ *
69
+ * Resolution order:
70
+ * 1. Memory / cache hit → resolves instantly
71
+ * 2. Default in defaults map → resolves instantly
72
+ * 3. Inline defaultValue arg → resolves instantly
73
+ * 4. No default → suspends until idle fetch or refresh
74
+ * delivers the key
75
+ *
76
+ * On timeout (global, default 30s):
77
+ * - onError is called with SdkError { type: "TIMEOUT", key }
78
+ * - Promise rejects with the same SdkError
79
+ */
80
+ get<T = unknown>(key: string): Promise<T>;
81
+ get<T = unknown>(key: string, defaultValue: T): Promise<T>;
82
+ /**
83
+ * Returns a snapshot of all currently fetched values merged with defaults.
84
+ * Does not wait for any in-flight fetches — returns current state only.
85
+ */
86
+ all(): Promise<Record<string, unknown>>;
87
+ /**
88
+ * Update user context. Fire-and-forget, debounced 100ms.
89
+ * Re-fetches all already-fetched keys in tier order with the new context.
90
+ * Subscribe to "updated" events to react when the re-fetch completes.
91
+ */
50
92
  setContext(context: EvaluationContext): void;
51
- /** Force re-fetch from the API. */
93
+ /**
94
+ * Re-fetch all already-fetched keys in tier order.
95
+ * Resolves when all re-fetches complete.
96
+ */
52
97
  refresh(): Promise<void>;
53
- /** Subscribe to events: "ready", "updated", "fetchError" */
54
- on<E extends ConfigEventType>(event: E, cb: ConfigEventCallback<E>): void;
98
+ /**
99
+ * Subscribe to events.
100
+ *
101
+ * Key-specific: flags.on("updated:feature.dark_mode", (value) => ...)
102
+ * Batch updated: flags.on("updated", ({ keys, source }) => ...)
103
+ * Fetch errors: flags.on("fetchError", ({ error }) => ...)
104
+ */
105
+ on(event: string, cb: (payload: unknown) => void): void;
55
106
  /** Unsubscribe from events. */
56
- off<E extends ConfigEventType>(event: E, cb: ConfigEventCallback<E>): void;
107
+ off(event: string, cb: (payload: unknown) => void): void;
57
108
  }
58
- /**
59
- * Initialize the feature flags SDK.
60
- *
61
- * Returns a Flags instance that immediately serves default values,
62
- * then resolves to real values from the API in the background.
63
- *
64
- * @example
65
- * ```ts
66
- * const flags = initConfig({
67
- * clientId: "cid_xxx",
68
- * defaults: { "feature.dark_mode": false },
69
- * context: autoContext({ userId: "user_123", plan: "pro" }),
70
- * });
71
- *
72
- * flags.get("feature.dark_mode"); // → false (instant)
73
- * flags.on("updated", () => {
74
- * flags.get("feature.dark_mode"); // → true (from API)
75
- * });
76
- * ```
77
- */
78
109
  export declare function initConfig(options: InitConfigOptions): Flags;
@@ -1,129 +1,399 @@
1
1
  // ═══════════════════════════════════════════════════════════════
2
2
  // initConfig — Primary SDK entry point
3
3
  //
4
- // Usage:
5
- // const flags = initConfig({
6
- // clientId: "cid_xxx",
7
- // defaults: { "feature.dark_mode": false, "app.upload_limit": 50 },
8
- // context: autoContext({ userId: "user_123", plan: "pro" }),
9
- // });
4
+ // Three-tier priority fetching model:
10
5
  //
11
- // flags.get("feature.dark_mode") // → false (instant, from defaults)
12
- // // ...API responds...
13
- // flags.get("feature.dark_mode") // → true (resolved from server)
6
+ // Tier 1 — PREFETCH (init)
7
+ // Declared via `prefetch: [...]` at initConfig time.
8
+ // Fetched immediately. ready() blocks until this completes.
9
+ //
10
+ // Tier 2 — PAGE (runtime)
11
+ // Declared via flags.prefetch(keys) per route/component.
12
+ // Fire-and-forget. Keys deduplicated against Tier 1.
13
+ //
14
+ // Tier 3 — IDLE
15
+ // Full fetchAll() during browser idle time via requestIdleCallback.
16
+ // Fills in all remaining keys in the background.
17
+ //
18
+ // get() behaviour:
19
+ // - Memory/cache hit → resolves instantly
20
+ // - Default provided → resolves instantly
21
+ // - No default → suspends until idle fetch or refresh delivers
22
+ // the key, or global timeout fires
23
+ // - Timeout → onError called, Promise rejects with SdkError
24
+ //
25
+ // Context update:
26
+ // setContext() is fire-and-forget (debounced 100ms).
27
+ // Re-fetches only already-fetched keys in tier order.
14
28
  // ═══════════════════════════════════════════════════════════════
15
- import { createConfig } from "./createConfig.js";
29
+ import { browserStorage } from "./cache/browserStorage.js";
30
+ import { SdkError, ConfigError } from "./errors/index.js";
31
+ import { TypedEventEmitter } from "./events/EventEmitter.js";
32
+ import { createProjectedFetcher } from "./fetch/projectedFetcher.js";
33
+ import { TierFetcher } from "./fetch/TierFetcher.js";
34
+ import { withRetry } from "./retry/RetryEngine.js";
35
+ import { createHttpTransport } from "./transport/HttpTransport.js";
36
+ import { DEFAULT_CACHE_TTL, DEFAULT_RETRY } from "./types.js";
37
+ const DEFAULT_BASE_URL = "https://jewel998-config.web.app/api";
38
+ const DEFAULT_TIMEOUT_MS = 30_000;
39
+ const CONTEXT_DEBOUNCE_MS = 100;
40
+ const MIN_REFETCH_INTERVAL_MS = 30_000;
41
+ /** Reserved storage keys for persisted gating metadata. */
42
+ const VERSION_STORAGE_KEY = "__version__";
43
+ const CONTEXT_FINGERPRINT_STORAGE_KEY = "__ctx__";
16
44
  /**
17
- * Initialize the feature flags SDK.
18
- *
19
- * Returns a Flags instance that immediately serves default values,
20
- * then resolves to real values from the API in the background.
21
- *
22
- * @example
23
- * ```ts
24
- * const flags = initConfig({
25
- * clientId: "cid_xxx",
26
- * defaults: { "feature.dark_mode": false },
27
- * context: autoContext({ userId: "user_123", plan: "pro" }),
28
- * });
29
- *
30
- * flags.get("feature.dark_mode"); // → false (instant)
31
- * flags.on("updated", () => {
32
- * flags.get("feature.dark_mode"); // → true (from API)
33
- * });
34
- * ```
45
+ * Stable string fingerprint of an evaluation context. Keys are sorted so the
46
+ * same context always produces the same fingerprint regardless of insertion
47
+ * order. Used to detect when context changed across loads (which invalidates
48
+ * persisted values even if the config version is unchanged).
35
49
  */
50
+ function contextFingerprint(ctx) {
51
+ const stable = (value) => {
52
+ if (value === null || typeof value !== "object")
53
+ return value;
54
+ if (Array.isArray(value))
55
+ return value.map(stable);
56
+ const obj = value;
57
+ const sorted = {};
58
+ for (const k of Object.keys(obj).sort())
59
+ sorted[k] = stable(obj[k]);
60
+ return sorted;
61
+ };
62
+ try {
63
+ return JSON.stringify(stable(ctx ?? {}));
64
+ }
65
+ catch {
66
+ return "";
67
+ }
68
+ }
69
+ // ─── Implementation ────────────────────────────────────────────
36
70
  export function initConfig(options) {
71
+ if (!options.clientId) {
72
+ throw new ConfigError("clientId is required", "MISSING_CLIENT_ID");
73
+ }
74
+ if (typeof window === "undefined" && !options.clientId.startsWith("svr_")) {
75
+ throw new ConfigError("Client keys (cid_) require a browser environment.", "INITIALIZATION_FAILED");
76
+ }
37
77
  const defaults = options.defaults ?? {};
38
- const pollInterval = options.pollInterval ?? 300_000; // 5 minutes default
39
- const baseUrl = options.baseUrl ?? "https://jewel998-config.web.app/api";
40
- // Create the underlying config client (optimistic strategy = instant return + background fetch)
41
- const client = createConfig({
42
- clientId: options.clientId,
43
- loadingStrategy: "optimistic",
44
- context: options.context,
45
- storage: options.storage,
78
+ const pollInterval = options.pollInterval ?? 300_000;
79
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
80
+ const storage = options.storage ?? browserStorage();
81
+ const retry = { ...DEFAULT_RETRY };
82
+ const timeoutMs = options.timeout ?? DEFAULT_TIMEOUT_MS;
83
+ const onError = options.onError;
84
+ const initPrefetch = options.prefetch ?? [];
85
+ const events = new TypedEventEmitter();
86
+ let currentContext = options.context ?? {};
87
+ // ── Transport + fetcher ──────────────────────────────────────
88
+ const transport = createHttpTransport({
46
89
  baseUrl,
90
+ clientId: options.clientId,
91
+ evaluationMode: "server",
92
+ getContext: () => currentContext,
47
93
  });
48
- // Version-based polling: check /api/version periodically, delta-fetch on change
49
- let cachedVersion = null;
50
- let pollTimer = null;
51
- async function checkVersion() {
94
+ const baseFetcher = createProjectedFetcher(transport);
95
+ const tierFetcher = new TierFetcher(baseFetcher);
96
+ // ── In-memory data store ─────────────────────────────────────
97
+ const data = {};
98
+ // Version known to the SDK. Hydrated from persistent storage on init so a
99
+ // reload can skip the config fetch when the version is unchanged.
100
+ let cachedVersion = storage.get(VERSION_STORAGE_KEY) ?? null;
101
+ const currentFingerprint = contextFingerprint(currentContext);
102
+ // If the persisted context fingerprint differs from this init's context, any
103
+ // persisted per-user values are stale — drop the persisted version so the
104
+ // boot gate re-fetches rather than serving values evaluated for old context.
105
+ if (storage.get(CONTEXT_FINGERPRINT_STORAGE_KEY) !== currentFingerprint) {
106
+ cachedVersion = null;
107
+ }
108
+ /** Persist the current version + context fingerprint next to the values. */
109
+ function persistVersion(version) {
110
+ cachedVersion = version;
111
+ storage.set(VERSION_STORAGE_KEY, version, DEFAULT_CACHE_TTL);
112
+ storage.set(CONTEXT_FINGERPRINT_STORAGE_KEY, currentFingerprint, DEFAULT_CACHE_TTL);
113
+ }
114
+ function applyResult(result) {
115
+ const keys = Object.keys(result);
116
+ for (const key of keys) {
117
+ data[key] = result[key];
118
+ storage.set(key, result[key], DEFAULT_CACHE_TTL);
119
+ }
120
+ // Update the __all__ snapshot incrementally
121
+ const existing = storage.get("__all__") ?? {};
122
+ storage.set("__all__", { ...existing, ...result }, DEFAULT_CACHE_TTL);
123
+ return keys;
124
+ }
125
+ function emitUpdated(keys, source) {
126
+ if (keys.length === 0)
127
+ return;
128
+ events.emit("updated", { keys, source });
129
+ }
130
+ // ── Remote version check (shared by boot gate + polling) ─────
131
+ async function fetchRemoteVersion() {
52
132
  try {
53
133
  const res = await fetch(`${baseUrl}/v1/version?clientId=${options.clientId}`, {
54
- headers: cachedVersion
55
- ? { "If-None-Match": `"${cachedVersion}"` }
56
- : {},
134
+ headers: cachedVersion ? { "If-None-Match": `"${cachedVersion}"` } : {},
57
135
  });
58
- // 304 = version unchanged, no action needed
136
+ // 304 => unchanged from the version we sent in If-None-Match.
59
137
  if (res.status === 304)
60
- return;
138
+ return cachedVersion;
61
139
  if (!res.ok)
62
- return;
63
- const { version, changedKeys } = (await res.json());
64
- // First check — just store version, no refetch (initial fetch already happened)
65
- if (cachedVersion === null) {
66
- cachedVersion = version;
67
- return;
140
+ return null;
141
+ const { version } = (await res.json());
142
+ return version ?? null;
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
148
+ /** True when every key is already present in persistent storage. */
149
+ function allKeysCached(keys) {
150
+ return keys.every((k) => storage.get(k) !== undefined);
151
+ }
152
+ function hydrateFromStorage(keys) {
153
+ const hydrated = [];
154
+ for (const key of keys) {
155
+ const cached = storage.get(key);
156
+ if (cached !== undefined) {
157
+ data[key] = cached;
158
+ hydrated.push(key);
68
159
  }
69
- // Version changed — need to refresh
70
- if (version !== cachedVersion) {
71
- cachedVersion = version;
72
- // If we know which keys changed, we could delta-fetch in the future
73
- // For now, full refresh (still efficient with CDN + deduplication)
74
- await client.refresh();
160
+ }
161
+ return hydrated;
162
+ }
163
+ // ── Tier 1: init prefetch (version-gated against persisted cache) ─
164
+ const readyPromise = initPrefetch.length > 0
165
+ ? (async () => {
166
+ try {
167
+ // Boot gate: if we have a persisted version and values, confirm the
168
+ // remote version matches before spending a /config call. On match,
169
+ // serve from storage and skip the fetch entirely.
170
+ if (cachedVersion !== null && allKeysCached(initPrefetch)) {
171
+ const remote = await fetchRemoteVersion();
172
+ if (remote !== null && remote === cachedVersion) {
173
+ const hydrated = hydrateFromStorage(initPrefetch);
174
+ tierFetcher.markFetched(initPrefetch, "prefetch");
175
+ emitUpdated(hydrated, "prefetch");
176
+ return;
177
+ }
178
+ // Version advanced (or unknown) — fall through to a real fetch.
179
+ if (remote !== null)
180
+ cachedVersion = remote;
181
+ }
182
+ const result = await withRetry(() => tierFetcher.fetchTier(initPrefetch, "prefetch"), retry);
183
+ const fetched = applyResult(result);
184
+ // Record the version these values correspond to.
185
+ const v = await fetchRemoteVersion();
186
+ if (v !== null)
187
+ persistVersion(v);
188
+ tierFetcher.notifyWaiters(result);
189
+ emitUpdated(fetched, "prefetch");
190
+ }
191
+ catch (err) {
192
+ const sdkErr = new SdkError("FETCH_FAILED", `Prefetch failed: ${err.message}`, undefined, err);
193
+ onError?.(sdkErr);
194
+ events.emit("fetchError", {
195
+ error: sdkErr,
196
+ retryCount: retry.maxRetries,
197
+ willRetry: false,
198
+ });
199
+ // Don't re-throw — app still runs on defaults
200
+ }
201
+ })()
202
+ : Promise.resolve();
203
+ // ── Tier 3: idle fetch (skipped when the full snapshot is fresh) ──
204
+ readyPromise.then(() => {
205
+ scheduleIdleFetch(async () => {
206
+ try {
207
+ // If we already hold the full snapshot for the current version,
208
+ // there is nothing to fill in — skip the idle /config call.
209
+ if (cachedVersion !== null && storage.get("__all__") !== undefined) {
210
+ const remote = await fetchRemoteVersion();
211
+ if (remote !== null && remote === cachedVersion)
212
+ return;
213
+ if (remote !== null)
214
+ cachedVersion = remote;
215
+ }
216
+ const result = await tierFetcher.fetchAll();
217
+ const fetched = applyResult(result);
218
+ const v = await fetchRemoteVersion();
219
+ if (v !== null)
220
+ persistVersion(v);
221
+ tierFetcher.notifyWaiters(result);
222
+ emitUpdated(fetched, "idle");
75
223
  }
224
+ catch {
225
+ // Idle fetch is best-effort — silently ignore
226
+ // Pending waiters will eventually time out via get()
227
+ }
228
+ });
229
+ });
230
+ // ── Version polling ──────────────────────────────────────────
231
+ async function checkVersion() {
232
+ const version = await fetchRemoteVersion();
233
+ if (version === null)
234
+ return;
235
+ if (cachedVersion === null) {
236
+ persistVersion(version);
237
+ return;
76
238
  }
77
- catch {
78
- // Silently ignore polling errors — cache continues to work
239
+ if (version !== cachedVersion) {
240
+ persistVersion(version);
241
+ await doRefresh();
79
242
  }
80
243
  }
81
- // Start polling if interval > 0
82
244
  if (pollInterval > 0 && typeof window !== "undefined") {
83
- // Initial version check after first fetch completes
84
- client.on("ready", () => {
85
- void checkVersion();
86
- });
87
- pollTimer = setInterval(checkVersion, pollInterval);
88
- // Also check on tab visibility change (user returns to tab)
245
+ readyPromise.then(() => void checkVersion());
246
+ setInterval(checkVersion, pollInterval);
89
247
  document.addEventListener("visibilitychange", () => {
90
- if (document.visibilityState === "visible") {
248
+ if (document.visibilityState === "visible")
91
249
  void checkVersion();
92
- }
93
250
  });
94
251
  }
95
- return {
96
- get(key) {
97
- const resolved = client.getValue(key);
98
- if (resolved !== undefined)
99
- return resolved;
100
- return defaults[key];
252
+ // ── Refresh (re-fetch fetched keys in tier order) ─────────────
253
+ let lastRefetchTime = 0;
254
+ async function doRefresh() {
255
+ if (Date.now() - lastRefetchTime < MIN_REFETCH_INTERVAL_MS)
256
+ return;
257
+ lastRefetchTime = Date.now();
258
+ const { prefetch: pf, page, idle } = tierFetcher.getFetchedKeys();
259
+ const allFetched = [];
260
+ const refetches = [];
261
+ if (pf.length > 0) {
262
+ refetches.push(withRetry(() => tierFetcher.refetchKeys(pf), retry)
263
+ .then((r) => {
264
+ applyResult(r);
265
+ tierFetcher.notifyWaiters(r);
266
+ allFetched.push(...Object.keys(r));
267
+ })
268
+ .catch(() => { }));
269
+ }
270
+ if (page.length > 0) {
271
+ refetches.push(withRetry(() => tierFetcher.refetchKeys(page), retry)
272
+ .then((r) => {
273
+ applyResult(r);
274
+ tierFetcher.notifyWaiters(r);
275
+ allFetched.push(...Object.keys(r));
276
+ })
277
+ .catch(() => { }));
278
+ }
279
+ if (idle.length > 0) {
280
+ refetches.push(tierFetcher
281
+ .fetchAll()
282
+ .then((r) => {
283
+ applyResult(r);
284
+ tierFetcher.notifyWaiters(r);
285
+ allFetched.push(...Object.keys(r));
286
+ })
287
+ .catch(() => { }));
288
+ }
289
+ await Promise.allSettled(refetches);
290
+ emitUpdated(allFetched, "refresh");
291
+ }
292
+ // ── Context re-fetch ─────────────────────────────────────────
293
+ let contextDebounce = null;
294
+ // ── Public API ───────────────────────────────────────────────
295
+ const flags = {
296
+ ready() {
297
+ return readyPromise;
298
+ },
299
+ prefetch(keys) {
300
+ void (async () => {
301
+ try {
302
+ const result = await withRetry(() => tierFetcher.fetchTier(keys, "page"), retry);
303
+ const fetched = applyResult(result);
304
+ tierFetcher.notifyWaiters(result);
305
+ emitUpdated(fetched, "page");
306
+ }
307
+ catch (err) {
308
+ const sdkErr = new SdkError("FETCH_FAILED", `Page prefetch failed: ${err.message}`, undefined, err);
309
+ onError?.(sdkErr);
310
+ events.emit("fetchError", {
311
+ error: sdkErr,
312
+ retryCount: retry.maxRetries,
313
+ willRetry: false,
314
+ });
315
+ }
316
+ })();
101
317
  },
102
- flag(key) {
103
- const resolved = client.getFlag(key);
104
- if (resolved)
105
- return true;
106
- const val = client.getValue(key);
107
- if (val !== undefined)
108
- return val === true;
109
- return defaults[key] === true;
318
+ get(key, defaultValue) {
319
+ // 1. Memory hit
320
+ if (key in data)
321
+ return Promise.resolve(data[key]);
322
+ // 2. Cache hit
323
+ const cached = storage.get(key);
324
+ if (cached !== undefined) {
325
+ data[key] = cached;
326
+ return Promise.resolve(cached);
327
+ }
328
+ // 3. Default from map
329
+ if (key in defaults)
330
+ return Promise.resolve(defaults[key]);
331
+ // 4. Inline default
332
+ if (defaultValue !== undefined)
333
+ return Promise.resolve(defaultValue);
334
+ // 5. Suspend — wait for idle fetch or refresh to deliver this key
335
+ return new Promise((resolve, reject) => {
336
+ const timer = setTimeout(() => {
337
+ const err = new SdkError("TIMEOUT", `get("${key}") timed out after ${timeoutMs}ms — key was not delivered by idle fetch or refresh`, key);
338
+ onError?.(err);
339
+ reject(err);
340
+ }, timeoutMs);
341
+ tierFetcher.waitForKey(key).then((value) => {
342
+ clearTimeout(timer);
343
+ resolve(value);
344
+ }, (err) => {
345
+ clearTimeout(timer);
346
+ const sdkErr = new SdkError("FETCH_FAILED", err.message, key, err);
347
+ onError?.(sdkErr);
348
+ reject(sdkErr);
349
+ });
350
+ });
110
351
  },
111
- all() {
112
- const resolved = client.getAll();
113
- return { ...defaults, ...resolved };
352
+ async all() {
353
+ const snapshot = storage.get("__all__") ?? {};
354
+ return { ...defaults, ...snapshot, ...data };
114
355
  },
115
356
  setContext(context) {
116
- client.setContext(context);
357
+ currentContext = { ...context };
358
+ if (contextDebounce)
359
+ clearTimeout(contextDebounce);
360
+ contextDebounce = setTimeout(() => {
361
+ contextDebounce = null;
362
+ void doRefresh();
363
+ }, CONTEXT_DEBOUNCE_MS);
117
364
  },
118
- refresh() {
119
- return client.refresh();
365
+ async refresh() {
366
+ await doRefresh();
120
367
  },
121
368
  on(event, cb) {
122
- client.on(event, cb);
369
+ // Key-specific: "updated:feature.dark_mode"
370
+ if (event.startsWith("updated:")) {
371
+ const key = event.slice("updated:".length);
372
+ events.onKey(key, cb);
373
+ return;
374
+ }
375
+ events.on(event, cb);
123
376
  },
124
377
  off(event, cb) {
125
- client.off(event, cb);
378
+ if (event.startsWith("updated:")) {
379
+ const key = event.slice("updated:".length);
380
+ events.offKey(key, cb);
381
+ return;
382
+ }
383
+ events.off(event, cb);
126
384
  },
127
385
  };
386
+ return flags;
387
+ }
388
+ // ─── Idle scheduling ──────────────────────────────────────────
389
+ function scheduleIdleFetch(fn) {
390
+ if (typeof window === "undefined")
391
+ return;
392
+ if ("requestIdleCallback" in window) {
393
+ window.requestIdleCallback(fn, { timeout: 2000 });
394
+ }
395
+ else {
396
+ setTimeout(fn, 200);
397
+ }
128
398
  }
129
399
  //# sourceMappingURL=initConfig.js.map
@@ -1,3 +1,3 @@
1
1
  export { archivedPlugin } from "./archivedPlugin.js";
2
- export { validateStateTransition, VALID_TRANSITIONS, } from "./stateTransitions.js";
2
+ export { validateStateTransition, VALID_TRANSITIONS } from "./stateTransitions.js";
3
3
  export type { LifecycleState } from "./stateTransitions.js";