@tanstack/offline-transactions 0.0.0 → 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/OfflineExecutor.cjs +141 -37
  3. package/dist/cjs/OfflineExecutor.cjs.map +1 -1
  4. package/dist/cjs/OfflineExecutor.d.cts +10 -1
  5. package/dist/cjs/api/OfflineAction.cjs +8 -1
  6. package/dist/cjs/api/OfflineAction.cjs.map +1 -1
  7. package/dist/cjs/index.d.cts +1 -1
  8. package/dist/cjs/storage/IndexedDBAdapter.cjs +39 -0
  9. package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
  10. package/dist/cjs/storage/IndexedDBAdapter.d.cts +8 -0
  11. package/dist/cjs/storage/LocalStorageAdapter.cjs +31 -0
  12. package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
  13. package/dist/cjs/storage/LocalStorageAdapter.d.cts +8 -0
  14. package/dist/cjs/telemetry/tracer.cjs +1 -1
  15. package/dist/cjs/telemetry/tracer.cjs.map +1 -1
  16. package/dist/cjs/types.cjs.map +1 -1
  17. package/dist/cjs/types.d.cts +9 -4
  18. package/dist/esm/OfflineExecutor.d.ts +10 -1
  19. package/dist/esm/OfflineExecutor.js +141 -37
  20. package/dist/esm/OfflineExecutor.js.map +1 -1
  21. package/dist/esm/api/OfflineAction.js +8 -1
  22. package/dist/esm/api/OfflineAction.js.map +1 -1
  23. package/dist/esm/index.d.ts +1 -1
  24. package/dist/esm/storage/IndexedDBAdapter.d.ts +8 -0
  25. package/dist/esm/storage/IndexedDBAdapter.js +39 -0
  26. package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
  27. package/dist/esm/storage/LocalStorageAdapter.d.ts +8 -0
  28. package/dist/esm/storage/LocalStorageAdapter.js +31 -0
  29. package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
  30. package/dist/esm/telemetry/tracer.js +1 -1
  31. package/dist/esm/telemetry/tracer.js.map +1 -1
  32. package/dist/esm/types.d.ts +9 -4
  33. package/dist/esm/types.js.map +1 -1
  34. package/package.json +12 -12
  35. package/src/OfflineExecutor.ts +200 -43
  36. package/src/api/OfflineAction.ts +14 -1
  37. package/src/index.ts +3 -0
  38. package/src/storage/IndexedDBAdapter.ts +47 -0
  39. package/src/storage/LocalStorageAdapter.ts +38 -0
  40. package/src/telemetry/tracer.ts +4 -9
  41. package/src/types.ts +19 -4
@@ -2,6 +2,14 @@ import { BaseStorageAdapter } from './StorageAdapter.js';
2
2
  export declare class LocalStorageAdapter extends BaseStorageAdapter {
3
3
  private prefix;
4
4
  constructor(prefix?: string);
5
+ /**
6
+ * Probe localStorage availability by attempting a test write.
7
+ * This catches private mode and other restrictions that block localStorage.
8
+ */
9
+ static probe(): {
10
+ available: boolean;
11
+ error?: Error;
12
+ };
5
13
  private getKey;
6
14
  get(key: string): Promise<string | null>;
7
15
  set(key: string, value: string): Promise<void>;
@@ -4,6 +4,37 @@ class LocalStorageAdapter extends BaseStorageAdapter {
4
4
  super();
5
5
  this.prefix = prefix;
6
6
  }
7
+ /**
8
+ * Probe localStorage availability by attempting a test write.
9
+ * This catches private mode and other restrictions that block localStorage.
10
+ */
11
+ static probe() {
12
+ if (typeof localStorage === `undefined`) {
13
+ return {
14
+ available: false,
15
+ error: new Error(`localStorage is not available in this environment`)
16
+ };
17
+ }
18
+ try {
19
+ const testKey = `__offline-tx-probe__`;
20
+ const testValue = `test`;
21
+ localStorage.setItem(testKey, testValue);
22
+ const retrieved = localStorage.getItem(testKey);
23
+ localStorage.removeItem(testKey);
24
+ if (retrieved !== testValue) {
25
+ return {
26
+ available: false,
27
+ error: new Error(`localStorage read/write verification failed`)
28
+ };
29
+ }
30
+ return { available: true };
31
+ } catch (error) {
32
+ return {
33
+ available: false,
34
+ error: error instanceof Error ? error : new Error(String(error))
35
+ };
36
+ }
37
+ }
7
38
  getKey(key) {
8
39
  return `${this.prefix}${key}`;
9
40
  }
@@ -1 +1 @@
1
- {"version":3,"file":"LocalStorageAdapter.js","sources":["../../../src/storage/LocalStorageAdapter.ts"],"sourcesContent":["import { BaseStorageAdapter } from \"./StorageAdapter\"\n\nexport class LocalStorageAdapter extends BaseStorageAdapter {\n private prefix: string\n\n constructor(prefix = `offline-tx:`) {\n super()\n this.prefix = prefix\n }\n\n private getKey(key: string): string {\n return `${this.prefix}${key}`\n }\n\n get(key: string): Promise<string | null> {\n try {\n return Promise.resolve(localStorage.getItem(this.getKey(key)))\n } catch (error) {\n console.warn(`localStorage get failed:`, error)\n return Promise.resolve(null)\n }\n }\n\n set(key: string, value: string): Promise<void> {\n try {\n localStorage.setItem(this.getKey(key), value)\n return Promise.resolve()\n } catch (error) {\n if (\n error instanceof DOMException &&\n error.name === `QuotaExceededError`\n ) {\n return Promise.reject(\n new Error(\n `Storage quota exceeded. Consider clearing old transactions.`\n )\n )\n }\n return Promise.reject(error)\n }\n }\n\n delete(key: string): Promise<void> {\n try {\n localStorage.removeItem(this.getKey(key))\n return Promise.resolve()\n } catch (error) {\n console.warn(`localStorage delete failed:`, error)\n return Promise.resolve()\n }\n }\n\n keys(): Promise<Array<string>> {\n try {\n const keys: Array<string> = []\n for (let i = 0; i < localStorage.length; i++) {\n const key = localStorage.key(i)\n if (key && key.startsWith(this.prefix)) {\n keys.push(key.slice(this.prefix.length))\n }\n }\n return Promise.resolve(keys)\n } catch (error) {\n console.warn(`localStorage keys failed:`, error)\n return Promise.resolve([])\n }\n }\n\n async clear(): Promise<void> {\n try {\n const keys = await this.keys()\n for (const key of keys) {\n localStorage.removeItem(this.getKey(key))\n }\n } catch (error) {\n console.warn(`localStorage clear failed:`, error)\n }\n }\n}\n"],"names":[],"mappings":";AAEO,MAAM,4BAA4B,mBAAmB;AAAA,EAG1D,YAAY,SAAS,eAAe;AAClC,UAAA;AACA,SAAK,SAAS;AAAA,EAChB;AAAA,EAEQ,OAAO,KAAqB;AAClC,WAAO,GAAG,KAAK,MAAM,GAAG,GAAG;AAAA,EAC7B;AAAA,EAEA,IAAI,KAAqC;AACvC,QAAI;AACF,aAAO,QAAQ,QAAQ,aAAa,QAAQ,KAAK,OAAO,GAAG,CAAC,CAAC;AAAA,IAC/D,SAAS,OAAO;AACd,cAAQ,KAAK,4BAA4B,KAAK;AAC9C,aAAO,QAAQ,QAAQ,IAAI;AAAA,IAC7B;AAAA,EACF;AAAA,EAEA,IAAI,KAAa,OAA8B;AAC7C,QAAI;AACF,mBAAa,QAAQ,KAAK,OAAO,GAAG,GAAG,KAAK;AAC5C,aAAO,QAAQ,QAAA;AAAA,IACjB,SAAS,OAAO;AACd,UACE,iBAAiB,gBACjB,MAAM,SAAS,sBACf;AACA,eAAO,QAAQ;AAAA,UACb,IAAI;AAAA,YACF;AAAA,UAAA;AAAA,QACF;AAAA,MAEJ;AACA,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAAA,EACF;AAAA,EAEA,OAAO,KAA4B;AACjC,QAAI;AACF,mBAAa,WAAW,KAAK,OAAO,GAAG,CAAC;AACxC,aAAO,QAAQ,QAAA;AAAA,IACjB,SAAS,OAAO;AACd,cAAQ,KAAK,+BAA+B,KAAK;AACjD,aAAO,QAAQ,QAAA;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,OAA+B;AAC7B,QAAI;AACF,YAAM,OAAsB,CAAA;AAC5B,eAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;AAC5C,cAAM,MAAM,aAAa,IAAI,CAAC;AAC9B,YAAI,OAAO,IAAI,WAAW,KAAK,MAAM,GAAG;AACtC,eAAK,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAAA,QACzC;AAAA,MACF;AACA,aAAO,QAAQ,QAAQ,IAAI;AAAA,IAC7B,SAAS,OAAO;AACd,cAAQ,KAAK,6BAA6B,KAAK;AAC/C,aAAO,QAAQ,QAAQ,EAAE;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI;AACF,YAAM,OAAO,MAAM,KAAK,KAAA;AACxB,iBAAW,OAAO,MAAM;AACtB,qBAAa,WAAW,KAAK,OAAO,GAAG,CAAC;AAAA,MAC1C;AAAA,IACF,SAAS,OAAO;AACd,cAAQ,KAAK,8BAA8B,KAAK;AAAA,IAClD;AAAA,EACF;AACF;"}
1
+ {"version":3,"file":"LocalStorageAdapter.js","sources":["../../../src/storage/LocalStorageAdapter.ts"],"sourcesContent":["import { BaseStorageAdapter } from \"./StorageAdapter\"\n\nexport class LocalStorageAdapter extends BaseStorageAdapter {\n private prefix: string\n\n constructor(prefix = `offline-tx:`) {\n super()\n this.prefix = prefix\n }\n\n /**\n * Probe localStorage availability by attempting a test write.\n * This catches private mode and other restrictions that block localStorage.\n */\n static probe(): { available: boolean; error?: Error } {\n // Check if localStorage exists\n if (typeof localStorage === `undefined`) {\n return {\n available: false,\n error: new Error(`localStorage is not available in this environment`),\n }\n }\n\n // Try to actually write/read/delete to verify it works\n try {\n const testKey = `__offline-tx-probe__`\n const testValue = `test`\n\n localStorage.setItem(testKey, testValue)\n const retrieved = localStorage.getItem(testKey)\n localStorage.removeItem(testKey)\n\n if (retrieved !== testValue) {\n return {\n available: false,\n error: new Error(`localStorage read/write verification failed`),\n }\n }\n\n return { available: true }\n } catch (error) {\n return {\n available: false,\n error: error instanceof Error ? error : new Error(String(error)),\n }\n }\n }\n\n private getKey(key: string): string {\n return `${this.prefix}${key}`\n }\n\n get(key: string): Promise<string | null> {\n try {\n return Promise.resolve(localStorage.getItem(this.getKey(key)))\n } catch (error) {\n console.warn(`localStorage get failed:`, error)\n return Promise.resolve(null)\n }\n }\n\n set(key: string, value: string): Promise<void> {\n try {\n localStorage.setItem(this.getKey(key), value)\n return Promise.resolve()\n } catch (error) {\n if (\n error instanceof DOMException &&\n error.name === `QuotaExceededError`\n ) {\n return Promise.reject(\n new Error(\n `Storage quota exceeded. Consider clearing old transactions.`\n )\n )\n }\n return Promise.reject(error)\n }\n }\n\n delete(key: string): Promise<void> {\n try {\n localStorage.removeItem(this.getKey(key))\n return Promise.resolve()\n } catch (error) {\n console.warn(`localStorage delete failed:`, error)\n return Promise.resolve()\n }\n }\n\n keys(): Promise<Array<string>> {\n try {\n const keys: Array<string> = []\n for (let i = 0; i < localStorage.length; i++) {\n const key = localStorage.key(i)\n if (key && key.startsWith(this.prefix)) {\n keys.push(key.slice(this.prefix.length))\n }\n }\n return Promise.resolve(keys)\n } catch (error) {\n console.warn(`localStorage keys failed:`, error)\n return Promise.resolve([])\n }\n }\n\n async clear(): Promise<void> {\n try {\n const keys = await this.keys()\n for (const key of keys) {\n localStorage.removeItem(this.getKey(key))\n }\n } catch (error) {\n console.warn(`localStorage clear failed:`, error)\n }\n }\n}\n"],"names":[],"mappings":";AAEO,MAAM,4BAA4B,mBAAmB;AAAA,EAG1D,YAAY,SAAS,eAAe;AAClC,UAAA;AACA,SAAK,SAAS;AAAA,EAChB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,QAA+C;AAEpD,QAAI,OAAO,iBAAiB,aAAa;AACvC,aAAO;AAAA,QACL,WAAW;AAAA,QACX,OAAO,IAAI,MAAM,mDAAmD;AAAA,MAAA;AAAA,IAExE;AAGA,QAAI;AACF,YAAM,UAAU;AAChB,YAAM,YAAY;AAElB,mBAAa,QAAQ,SAAS,SAAS;AACvC,YAAM,YAAY,aAAa,QAAQ,OAAO;AAC9C,mBAAa,WAAW,OAAO;AAE/B,UAAI,cAAc,WAAW;AAC3B,eAAO;AAAA,UACL,WAAW;AAAA,UACX,OAAO,IAAI,MAAM,6CAA6C;AAAA,QAAA;AAAA,MAElE;AAEA,aAAO,EAAE,WAAW,KAAA;AAAA,IACtB,SAAS,OAAO;AACd,aAAO;AAAA,QACL,WAAW;AAAA,QACX,OAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AAAA,MAAA;AAAA,IAEnE;AAAA,EACF;AAAA,EAEQ,OAAO,KAAqB;AAClC,WAAO,GAAG,KAAK,MAAM,GAAG,GAAG;AAAA,EAC7B;AAAA,EAEA,IAAI,KAAqC;AACvC,QAAI;AACF,aAAO,QAAQ,QAAQ,aAAa,QAAQ,KAAK,OAAO,GAAG,CAAC,CAAC;AAAA,IAC/D,SAAS,OAAO;AACd,cAAQ,KAAK,4BAA4B,KAAK;AAC9C,aAAO,QAAQ,QAAQ,IAAI;AAAA,IAC7B;AAAA,EACF;AAAA,EAEA,IAAI,KAAa,OAA8B;AAC7C,QAAI;AACF,mBAAa,QAAQ,KAAK,OAAO,GAAG,GAAG,KAAK;AAC5C,aAAO,QAAQ,QAAA;AAAA,IACjB,SAAS,OAAO;AACd,UACE,iBAAiB,gBACjB,MAAM,SAAS,sBACf;AACA,eAAO,QAAQ;AAAA,UACb,IAAI;AAAA,YACF;AAAA,UAAA;AAAA,QACF;AAAA,MAEJ;AACA,aAAO,QAAQ,OAAO,KAAK;AAAA,IAC7B;AAAA,EACF;AAAA,EAEA,OAAO,KAA4B;AACjC,QAAI;AACF,mBAAa,WAAW,KAAK,OAAO,GAAG,CAAC;AACxC,aAAO,QAAQ,QAAA;AAAA,IACjB,SAAS,OAAO;AACd,cAAQ,KAAK,+BAA+B,KAAK;AACjD,aAAO,QAAQ,QAAA;AAAA,IACjB;AAAA,EACF;AAAA,EAEA,OAA+B;AAC7B,QAAI;AACF,YAAM,OAAsB,CAAA;AAC5B,eAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,KAAK;AAC5C,cAAM,MAAM,aAAa,IAAI,CAAC;AAC9B,YAAI,OAAO,IAAI,WAAW,KAAK,MAAM,GAAG;AACtC,eAAK,KAAK,IAAI,MAAM,KAAK,OAAO,MAAM,CAAC;AAAA,QACzC;AAAA,MACF;AACA,aAAO,QAAQ,QAAQ,IAAI;AAAA,IAC7B,SAAS,OAAO;AACd,cAAQ,KAAK,6BAA6B,KAAK;AAC/C,aAAO,QAAQ,QAAQ,EAAE;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,MAAM,QAAuB;AAC3B,QAAI;AACF,YAAM,OAAO,MAAM,KAAK,KAAA;AACxB,iBAAW,OAAO,MAAM;AACtB,qBAAa,WAAW,KAAK,OAAO,GAAG,CAAC;AAAA,MAC1C;AAAA,IACF,SAAS,OAAO;AACd,cAAQ,KAAK,8BAA8B,KAAK;AAAA,IAClD;AAAA,EACF;AACF;"}
@@ -1,5 +1,5 @@
1
1
  import { trace, SpanStatusCode, context } from "@opentelemetry/api";
2
- const TRACER = trace.getTracer("@tanstack/offline-transactions", "0.0.1");
2
+ const TRACER = trace.getTracer(`@tanstack/offline-transactions`, `0.0.1`);
3
3
  function getParentContext(options) {
4
4
  if (options?.parentContext) {
5
5
  const parentSpan = trace.wrapSpanContext(options.parentContext);
@@ -1 +1 @@
1
- {"version":3,"file":"tracer.js","sources":["../../../src/telemetry/tracer.ts"],"sourcesContent":["import {\n trace,\n type Span,\n SpanStatusCode,\n context,\n type SpanContext,\n} from \"@opentelemetry/api\"\n\nconst TRACER = trace.getTracer(\"@tanstack/offline-transactions\", \"0.0.1\")\n\nexport interface SpanAttrs {\n [key: string]: string | number | boolean | undefined\n}\n\ninterface WithSpanOptions {\n parentContext?: SpanContext\n}\n\nfunction getParentContext(options?: WithSpanOptions) {\n if (options?.parentContext) {\n const parentSpan = trace.wrapSpanContext(options.parentContext)\n return trace.setSpan(context.active(), parentSpan)\n }\n\n return context.active()\n}\n\n/**\n * Lightweight span wrapper with error handling.\n * Uses OpenTelemetry API which is no-op when tracing is disabled.\n *\n * By default, creates spans at the current context level (siblings).\n * Use withNestedSpan if you want parent-child relationships.\n */\nexport async function withSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => Promise<T>,\n options?: WithSpanOptions\n): Promise<T> {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n try {\n const result = await fn(span)\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Like withSpan but propagates context so child spans nest properly.\n * Use this when you want operations inside fn to be child spans.\n */\nexport async function withNestedSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => Promise<T>,\n options?: WithSpanOptions\n): Promise<T> {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n // Set the span as active context so child spans nest properly\n const ctx = trace.setSpan(parentCtx, span)\n\n try {\n // Execute the function within the span's context\n const result = await context.with(ctx, () => fn(span))\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Creates a synchronous span for non-async operations\n */\nexport function withSyncSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => T,\n options?: WithSpanOptions\n): T {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n try {\n const result = fn(span)\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Get the current tracer instance\n */\nexport function getTracer() {\n return TRACER\n}\n"],"names":[],"mappings":";AAQA,MAAM,SAAS,MAAM,UAAU,kCAAkC,OAAO;AAUxE,SAAS,iBAAiB,SAA2B;AACnD,MAAI,SAAS,eAAe;AAC1B,UAAM,aAAa,MAAM,gBAAgB,QAAQ,aAAa;AAC9D,WAAO,MAAM,QAAQ,QAAQ,OAAA,GAAU,UAAU;AAAA,EACnD;AAEA,SAAO,QAAQ,OAAA;AACjB;AASA,eAAsB,SACpB,MACA,OACA,IACA,SACY;AACZ,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAEhC,MAAI;AACF,UAAM,SAAS,MAAM,GAAG,IAAI;AAC5B,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;AAMA,eAAsB,eACpB,MACA,OACA,IACA,SACY;AACZ,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAGhC,QAAM,MAAM,MAAM,QAAQ,WAAW,IAAI;AAEzC,MAAI;AAEF,UAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,MAAM,GAAG,IAAI,CAAC;AACrD,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;AAKO,SAAS,aACd,MACA,OACA,IACA,SACG;AACH,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAEhC,MAAI;AACF,UAAM,SAAS,GAAG,IAAI;AACtB,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;"}
1
+ {"version":3,"file":"tracer.js","sources":["../../../src/telemetry/tracer.ts"],"sourcesContent":["import { SpanStatusCode, context, trace } from \"@opentelemetry/api\"\nimport type { Span, SpanContext } from \"@opentelemetry/api\"\n\nconst TRACER = trace.getTracer(`@tanstack/offline-transactions`, `0.0.1`)\n\nexport interface SpanAttrs {\n [key: string]: string | number | boolean | undefined\n}\n\ninterface WithSpanOptions {\n parentContext?: SpanContext\n}\n\nfunction getParentContext(options?: WithSpanOptions) {\n if (options?.parentContext) {\n const parentSpan = trace.wrapSpanContext(options.parentContext)\n return trace.setSpan(context.active(), parentSpan)\n }\n\n return context.active()\n}\n\n/**\n * Lightweight span wrapper with error handling.\n * Uses OpenTelemetry API which is no-op when tracing is disabled.\n *\n * By default, creates spans at the current context level (siblings).\n * Use withNestedSpan if you want parent-child relationships.\n */\nexport async function withSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => Promise<T>,\n options?: WithSpanOptions\n): Promise<T> {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n try {\n const result = await fn(span)\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Like withSpan but propagates context so child spans nest properly.\n * Use this when you want operations inside fn to be child spans.\n */\nexport async function withNestedSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => Promise<T>,\n options?: WithSpanOptions\n): Promise<T> {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n // Set the span as active context so child spans nest properly\n const ctx = trace.setSpan(parentCtx, span)\n\n try {\n // Execute the function within the span's context\n const result = await context.with(ctx, () => fn(span))\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Creates a synchronous span for non-async operations\n */\nexport function withSyncSpan<T>(\n name: string,\n attrs: SpanAttrs,\n fn: (span: Span) => T,\n options?: WithSpanOptions\n): T {\n const parentCtx = getParentContext(options)\n const span = TRACER.startSpan(name, undefined, parentCtx)\n\n // Filter out undefined attributes\n const filteredAttrs: Record<string, string | number | boolean> = {}\n for (const [key, value] of Object.entries(attrs)) {\n if (value !== undefined) {\n filteredAttrs[key] = value\n }\n }\n\n span.setAttributes(filteredAttrs)\n\n try {\n const result = fn(span)\n span.setStatus({ code: SpanStatusCode.OK })\n return result\n } catch (error) {\n span.setStatus({\n code: SpanStatusCode.ERROR,\n message: error instanceof Error ? error.message : String(error),\n })\n span.recordException(error as Error)\n throw error\n } finally {\n span.end()\n }\n}\n\n/**\n * Get the current tracer instance\n */\nexport function getTracer() {\n return TRACER\n}\n"],"names":[],"mappings":";AAGA,MAAM,SAAS,MAAM,UAAU,kCAAkC,OAAO;AAUxE,SAAS,iBAAiB,SAA2B;AACnD,MAAI,SAAS,eAAe;AAC1B,UAAM,aAAa,MAAM,gBAAgB,QAAQ,aAAa;AAC9D,WAAO,MAAM,QAAQ,QAAQ,OAAA,GAAU,UAAU;AAAA,EACnD;AAEA,SAAO,QAAQ,OAAA;AACjB;AASA,eAAsB,SACpB,MACA,OACA,IACA,SACY;AACZ,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAEhC,MAAI;AACF,UAAM,SAAS,MAAM,GAAG,IAAI;AAC5B,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;AAMA,eAAsB,eACpB,MACA,OACA,IACA,SACY;AACZ,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAGhC,QAAM,MAAM,MAAM,QAAQ,WAAW,IAAI;AAEzC,MAAI;AAEF,UAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,MAAM,GAAG,IAAI,CAAC;AACrD,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;AAKO,SAAS,aACd,MACA,OACA,IACA,SACG;AACH,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,OAAO,OAAO,UAAU,MAAM,QAAW,SAAS;AAGxD,QAAM,gBAA2D,CAAA;AACjE,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,GAAG;AAChD,QAAI,UAAU,QAAW;AACvB,oBAAc,GAAG,IAAI;AAAA,IACvB;AAAA,EACF;AAEA,OAAK,cAAc,aAAa;AAEhC,MAAI;AACF,UAAM,SAAS,GAAG,IAAI;AACtB,SAAK,UAAU,EAAE,MAAM,eAAe,IAAI;AAC1C,WAAO;AAAA,EACT,SAAS,OAAO;AACd,SAAK,UAAU;AAAA,MACb,MAAM,eAAe;AAAA,MACrB,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,IAAA,CAC/D;AACD,SAAK,gBAAgB,KAAc;AACnC,UAAM;AAAA,EACR,UAAA;AACE,SAAK,IAAA;AAAA,EACP;AACF;"}
@@ -49,6 +49,14 @@ export interface SerializedOfflineTransaction {
49
49
  spanContext?: SerializedSpanContext;
50
50
  version: 1;
51
51
  }
52
+ export type OfflineMode = `offline` | `online-only`;
53
+ export type StorageDiagnosticCode = `STORAGE_AVAILABLE` | `INDEXEDDB_UNAVAILABLE` | `LOCALSTORAGE_UNAVAILABLE` | `STORAGE_BLOCKED` | `QUOTA_EXCEEDED` | `UNKNOWN_ERROR`;
54
+ export interface StorageDiagnostic {
55
+ code: StorageDiagnosticCode;
56
+ mode: OfflineMode;
57
+ message: string;
58
+ error?: Error;
59
+ }
52
60
  export interface OfflineConfig {
53
61
  collections: Record<string, Collection>;
54
62
  mutationFns: Record<string, OfflineMutationFn>;
@@ -58,11 +66,8 @@ export interface OfflineConfig {
58
66
  beforeRetry?: (transactions: Array<OfflineTransaction>) => Array<OfflineTransaction>;
59
67
  onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void;
60
68
  onLeadershipChange?: (isLeader: boolean) => void;
69
+ onStorageFailure?: (diagnostic: StorageDiagnostic) => void;
61
70
  leaderElection?: LeaderElection;
62
- otel?: {
63
- endpoint: string;
64
- headers?: Record<string, string>;
65
- };
66
71
  }
67
72
  export interface StorageAdapter {
68
73
  get: (key: string) => Promise<string | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n Collection,\n MutationFnParams,\n PendingMutation,\n} from \"@tanstack/db\"\n\n// Extended mutation function that includes idempotency key\nexport type OfflineMutationFnParams<\n T extends object = Record<string, unknown>,\n> = MutationFnParams<T> & {\n idempotencyKey: string\n}\n\nexport type OfflineMutationFn<T extends object = Record<string, unknown>> = (\n params: OfflineMutationFnParams<T>\n) => Promise<any>\n\n// Simplified mutation structure for serialization\nexport interface SerializedMutation {\n globalKey: string\n type: string\n modified: any\n original: any\n collectionId: string\n}\n\nexport interface SerializedError {\n name: string\n message: string\n stack?: string\n}\n\nexport interface SerializedSpanContext {\n traceId: string\n spanId: string\n traceFlags: number\n traceState?: string\n}\n\n// In-memory representation with full PendingMutation objects\nexport interface OfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<PendingMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Serialized representation for storage\nexport interface SerializedOfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<SerializedMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\nexport interface OfflineConfig {\n collections: Record<string, Collection>\n mutationFns: Record<string, OfflineMutationFn>\n storage?: StorageAdapter\n maxConcurrency?: number\n jitter?: boolean\n beforeRetry?: (\n transactions: Array<OfflineTransaction>\n ) => Array<OfflineTransaction>\n onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void\n onLeadershipChange?: (isLeader: boolean) => void\n leaderElection?: LeaderElection\n otel?: {\n endpoint: string\n headers?: Record<string, string>\n }\n}\n\nexport interface StorageAdapter {\n get: (key: string) => Promise<string | null>\n set: (key: string, value: string) => Promise<void>\n delete: (key: string) => Promise<void>\n keys: () => Promise<Array<string>>\n clear: () => Promise<void>\n}\n\nexport interface RetryPolicy {\n calculateDelay: (retryCount: number) => number\n shouldRetry: (error: Error, retryCount: number) => boolean\n}\n\nexport interface LeaderElection {\n requestLeadership: () => Promise<boolean>\n releaseLeadership: () => void\n isLeader: () => boolean\n onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void\n}\n\nexport interface OnlineDetector {\n subscribe: (callback: () => void) => () => void\n notifyOnline: () => void\n}\n\nexport interface CreateOfflineTransactionOptions {\n id?: string\n mutationFnName: string\n autoCommit?: boolean\n idempotencyKey?: string\n metadata?: Record<string, any>\n}\n\nexport interface CreateOfflineActionOptions<T> {\n mutationFnName: string\n onMutate: (variables: T) => void\n}\n\nexport class NonRetriableError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `NonRetriableError`\n }\n}\n"],"names":[],"mappings":"AA+HO,MAAM,0BAA0B,MAAM;AAAA,EAC3C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;"}
1
+ {"version":3,"file":"types.js","sources":["../../src/types.ts"],"sourcesContent":["import type {\n Collection,\n MutationFnParams,\n PendingMutation,\n} from \"@tanstack/db\"\n\n// Extended mutation function that includes idempotency key\nexport type OfflineMutationFnParams<\n T extends object = Record<string, unknown>,\n> = MutationFnParams<T> & {\n idempotencyKey: string\n}\n\nexport type OfflineMutationFn<T extends object = Record<string, unknown>> = (\n params: OfflineMutationFnParams<T>\n) => Promise<any>\n\n// Simplified mutation structure for serialization\nexport interface SerializedMutation {\n globalKey: string\n type: string\n modified: any\n original: any\n collectionId: string\n}\n\nexport interface SerializedError {\n name: string\n message: string\n stack?: string\n}\n\nexport interface SerializedSpanContext {\n traceId: string\n spanId: string\n traceFlags: number\n traceState?: string\n}\n\n// In-memory representation with full PendingMutation objects\nexport interface OfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<PendingMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Serialized representation for storage\nexport interface SerializedOfflineTransaction {\n id: string\n mutationFnName: string\n mutations: Array<SerializedMutation>\n keys: Array<string>\n idempotencyKey: string\n createdAt: Date\n retryCount: number\n nextAttemptAt: number\n lastError?: SerializedError\n metadata?: Record<string, any>\n spanContext?: SerializedSpanContext\n version: 1\n}\n\n// Storage diagnostics and mode\nexport type OfflineMode = `offline` | `online-only`\n\nexport type StorageDiagnosticCode =\n | `STORAGE_AVAILABLE`\n | `INDEXEDDB_UNAVAILABLE`\n | `LOCALSTORAGE_UNAVAILABLE`\n | `STORAGE_BLOCKED`\n | `QUOTA_EXCEEDED`\n | `UNKNOWN_ERROR`\n\nexport interface StorageDiagnostic {\n code: StorageDiagnosticCode\n mode: OfflineMode\n message: string\n error?: Error\n}\n\nexport interface OfflineConfig {\n collections: Record<string, Collection>\n mutationFns: Record<string, OfflineMutationFn>\n storage?: StorageAdapter\n maxConcurrency?: number\n jitter?: boolean\n beforeRetry?: (\n transactions: Array<OfflineTransaction>\n ) => Array<OfflineTransaction>\n onUnknownMutationFn?: (name: string, tx: OfflineTransaction) => void\n onLeadershipChange?: (isLeader: boolean) => void\n onStorageFailure?: (diagnostic: StorageDiagnostic) => void\n leaderElection?: LeaderElection\n}\n\nexport interface StorageAdapter {\n get: (key: string) => Promise<string | null>\n set: (key: string, value: string) => Promise<void>\n delete: (key: string) => Promise<void>\n keys: () => Promise<Array<string>>\n clear: () => Promise<void>\n}\n\nexport interface RetryPolicy {\n calculateDelay: (retryCount: number) => number\n shouldRetry: (error: Error, retryCount: number) => boolean\n}\n\nexport interface LeaderElection {\n requestLeadership: () => Promise<boolean>\n releaseLeadership: () => void\n isLeader: () => boolean\n onLeadershipChange: (callback: (isLeader: boolean) => void) => () => void\n}\n\nexport interface OnlineDetector {\n subscribe: (callback: () => void) => () => void\n notifyOnline: () => void\n}\n\nexport interface CreateOfflineTransactionOptions {\n id?: string\n mutationFnName: string\n autoCommit?: boolean\n idempotencyKey?: string\n metadata?: Record<string, any>\n}\n\nexport interface CreateOfflineActionOptions<T> {\n mutationFnName: string\n onMutate: (variables: T) => void\n}\n\nexport class NonRetriableError extends Error {\n constructor(message: string) {\n super(message)\n this.name = `NonRetriableError`\n }\n}\n"],"names":[],"mappings":"AA8IO,MAAM,0BAA0B,MAAM;AAAA,EAC3C,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/offline-transactions",
3
- "version": "0.0.0",
3
+ "version": "0.1.0",
4
4
  "description": "Offline-first transaction capabilities for TanStack DB",
5
5
  "author": "TanStack",
6
6
  "license": "MIT",
@@ -42,17 +42,9 @@
42
42
  "dist",
43
43
  "src"
44
44
  ],
45
- "scripts": {
46
- "build": "vite build",
47
- "dev": "vite build --watch",
48
- "test": "vitest",
49
- "test:watch": "vitest --watch",
50
- "typecheck": "tsc --noEmit",
51
- "lint": "eslint src"
52
- },
53
45
  "dependencies": {
54
46
  "@opentelemetry/api": "^1.9.0",
55
- "@tanstack/db": "workspace:*"
47
+ "@tanstack/db": "0.4.17"
56
48
  },
57
49
  "devDependencies": {
58
50
  "@types/node": "^20.0.0",
@@ -61,6 +53,14 @@
61
53
  "vitest": "^3.2.4"
62
54
  },
63
55
  "peerDependencies": {
64
- "@tanstack/db": "workspace:*"
56
+ "@tanstack/db": "*"
57
+ },
58
+ "scripts": {
59
+ "build": "vite build",
60
+ "dev": "vite build --watch",
61
+ "test": "vitest",
62
+ "test:watch": "vitest --watch",
63
+ "typecheck": "tsc --noEmit",
64
+ "lint": "eslint src"
65
65
  }
66
- }
66
+ }
@@ -28,23 +28,36 @@ import type {
28
28
  CreateOfflineTransactionOptions,
29
29
  LeaderElection,
30
30
  OfflineConfig,
31
+ OfflineMode,
31
32
  OfflineTransaction,
32
33
  StorageAdapter,
34
+ StorageDiagnostic,
33
35
  } from "./types"
34
36
  import type { Transaction } from "@tanstack/db"
35
37
 
36
38
  export class OfflineExecutor {
37
39
  private config: OfflineConfig
38
- private storage: StorageAdapter
39
- private outbox: OutboxManager
40
+
41
+ // @ts-expect-error - Set during async initialization in initialize()
42
+ private storage: StorageAdapter | null
43
+ private outbox: OutboxManager | null
40
44
  private scheduler: KeyScheduler
41
- private executor: TransactionExecutor
42
- private leaderElection: LeaderElection
45
+ private executor: TransactionExecutor | null
46
+ private leaderElection: LeaderElection | null
43
47
  private onlineDetector: DefaultOnlineDetector
44
48
  private isLeaderState = false
45
49
  private unsubscribeOnline: (() => void) | null = null
46
50
  private unsubscribeLeadership: (() => void) | null = null
47
51
 
52
+ // Public diagnostic properties
53
+ public readonly mode: OfflineMode
54
+ public readonly storageDiagnostic: StorageDiagnostic
55
+
56
+ // Track initialization completion
57
+ private initPromise: Promise<void>
58
+ private initResolve!: () => void
59
+ private initReject!: (error: Error) => void
60
+
48
61
  // Coordination mechanism for blocking transactions
49
62
  private pendingTransactionPromises: Map<
50
63
  string,
@@ -57,35 +70,109 @@ export class OfflineExecutor {
57
70
 
58
71
  constructor(config: OfflineConfig) {
59
72
  this.config = config
60
- this.storage = this.createStorage()
61
- this.outbox = new OutboxManager(this.storage, this.config.collections)
62
73
  this.scheduler = new KeyScheduler()
63
- this.executor = new TransactionExecutor(
64
- this.scheduler,
65
- this.outbox,
66
- this.config,
67
- this
68
- )
69
- this.leaderElection = this.createLeaderElection()
70
74
  this.onlineDetector = new DefaultOnlineDetector()
71
75
 
72
- this.setupEventListeners()
76
+ // Initialize as pending - will be set by async initialization
77
+ this.storage = null
78
+ this.outbox = null
79
+ this.executor = null
80
+ this.leaderElection = null
81
+
82
+ // Temporary diagnostic - will be updated by async initialization
83
+ this.mode = `offline`
84
+ this.storageDiagnostic = {
85
+ code: `STORAGE_AVAILABLE`,
86
+ mode: `offline`,
87
+ message: `Initializing storage...`,
88
+ }
89
+
90
+ // Create initialization promise
91
+ this.initPromise = new Promise((resolve, reject) => {
92
+ this.initResolve = resolve
93
+ this.initReject = reject
94
+ })
95
+
73
96
  this.initialize()
74
97
  }
75
98
 
76
- private createStorage(): StorageAdapter {
99
+ /**
100
+ * Probe storage availability and create appropriate adapter.
101
+ * Returns null if no storage is available (online-only mode).
102
+ */
103
+ private async createStorage(): Promise<{
104
+ storage: StorageAdapter | null
105
+ diagnostic: StorageDiagnostic
106
+ }> {
107
+ // If user provided custom storage, use it without probing
77
108
  if (this.config.storage) {
78
- return this.config.storage
109
+ return {
110
+ storage: this.config.storage,
111
+ diagnostic: {
112
+ code: `STORAGE_AVAILABLE`,
113
+ mode: `offline`,
114
+ message: `Using custom storage adapter`,
115
+ },
116
+ }
79
117
  }
80
118
 
81
- try {
82
- return new IndexedDBAdapter()
83
- } catch (error) {
84
- console.warn(
85
- `IndexedDB not available, falling back to localStorage:`,
86
- error
87
- )
88
- return new LocalStorageAdapter()
119
+ // Probe IndexedDB first
120
+ const idbProbe = await IndexedDBAdapter.probe()
121
+ if (idbProbe.available) {
122
+ return {
123
+ storage: new IndexedDBAdapter(),
124
+ diagnostic: {
125
+ code: `STORAGE_AVAILABLE`,
126
+ mode: `offline`,
127
+ message: `Using IndexedDB for offline storage`,
128
+ },
129
+ }
130
+ }
131
+
132
+ // IndexedDB failed, try localStorage
133
+ const lsProbe = LocalStorageAdapter.probe()
134
+ if (lsProbe.available) {
135
+ return {
136
+ storage: new LocalStorageAdapter(),
137
+ diagnostic: {
138
+ code: `INDEXEDDB_UNAVAILABLE`,
139
+ mode: `offline`,
140
+ message: `IndexedDB unavailable, using localStorage fallback`,
141
+ error: idbProbe.error,
142
+ },
143
+ }
144
+ }
145
+
146
+ // Both failed - determine the diagnostic code
147
+ const isSecurityError =
148
+ idbProbe.error?.name === `SecurityError` ||
149
+ lsProbe.error?.name === `SecurityError`
150
+ const isQuotaError =
151
+ idbProbe.error?.name === `QuotaExceededError` ||
152
+ lsProbe.error?.name === `QuotaExceededError`
153
+
154
+ let code: StorageDiagnostic[`code`]
155
+ let message: string
156
+
157
+ if (isSecurityError) {
158
+ code = `STORAGE_BLOCKED`
159
+ message = `Storage blocked (private mode or security restrictions). Running in online-only mode.`
160
+ } else if (isQuotaError) {
161
+ code = `QUOTA_EXCEEDED`
162
+ message = `Storage quota exceeded. Running in online-only mode.`
163
+ } else {
164
+ code = `UNKNOWN_ERROR`
165
+ message = `Storage unavailable due to unknown error. Running in online-only mode.`
166
+ }
167
+
168
+ return {
169
+ storage: null,
170
+ diagnostic: {
171
+ code,
172
+ mode: `online-only`,
173
+ message,
174
+ error: idbProbe.error || lsProbe.error,
175
+ },
89
176
  }
90
177
  }
91
178
 
@@ -110,22 +197,25 @@ export class OfflineExecutor {
110
197
  }
111
198
 
112
199
  private setupEventListeners(): void {
113
- this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
114
- (isLeader) => {
115
- this.isLeaderState = isLeader
116
-
117
- if (this.config.onLeadershipChange) {
118
- this.config.onLeadershipChange(isLeader)
200
+ // Only set up leader election listeners if we have storage
201
+ if (this.leaderElection) {
202
+ this.unsubscribeLeadership = this.leaderElection.onLeadershipChange(
203
+ (isLeader) => {
204
+ this.isLeaderState = isLeader
205
+
206
+ if (this.config.onLeadershipChange) {
207
+ this.config.onLeadershipChange(isLeader)
208
+ }
209
+
210
+ if (isLeader) {
211
+ this.loadAndReplayTransactions()
212
+ }
119
213
  }
120
-
121
- if (isLeader) {
122
- this.loadAndReplayTransactions()
123
- }
124
- }
125
- )
214
+ )
215
+ }
126
216
 
127
217
  this.unsubscribeOnline = this.onlineDetector.subscribe(() => {
128
- if (this.isOfflineEnabled) {
218
+ if (this.isOfflineEnabled && this.executor) {
129
219
  // Reset retry delays so transactions can execute immediately when back online
130
220
  this.executor.resetRetryDelays()
131
221
  this.executor.executeAll().catch((error) => {
@@ -141,19 +231,65 @@ export class OfflineExecutor {
141
231
  private async initialize(): Promise<void> {
142
232
  return withSpan(`executor.initialize`, {}, async (span) => {
143
233
  try {
234
+ // Probe storage and create adapter
235
+ const { storage, diagnostic } = await this.createStorage()
236
+
237
+ // Cast to writable to set readonly properties
238
+ ;(this as any).storage = storage
239
+ ;(this as any).storageDiagnostic = diagnostic
240
+ ;(this as any).mode = diagnostic.mode
241
+
242
+ span.setAttribute(`storage.mode`, diagnostic.mode)
243
+ span.setAttribute(`storage.code`, diagnostic.code)
244
+
245
+ if (!storage) {
246
+ // Online-only mode - notify callback and skip offline setup
247
+ if (this.config.onStorageFailure) {
248
+ this.config.onStorageFailure(diagnostic)
249
+ }
250
+ span.setAttribute(`result`, `online-only`)
251
+ this.initResolve()
252
+ return
253
+ }
254
+
255
+ // Storage available - set up offline components
256
+ this.outbox = new OutboxManager(storage, this.config.collections)
257
+ this.executor = new TransactionExecutor(
258
+ this.scheduler,
259
+ this.outbox,
260
+ this.config,
261
+ this
262
+ )
263
+ this.leaderElection = this.createLeaderElection()
264
+
265
+ // Request leadership first
144
266
  const isLeader = await this.leaderElection.requestLeadership()
145
267
  span.setAttribute(`isLeader`, isLeader)
146
268
 
269
+ // Set up event listeners after leadership is established
270
+ // This prevents the callback from being called multiple times
271
+ this.setupEventListeners()
272
+
147
273
  if (isLeader) {
148
274
  await this.loadAndReplayTransactions()
149
275
  }
276
+ span.setAttribute(`result`, `offline-enabled`)
277
+ this.initResolve()
150
278
  } catch (error) {
151
279
  console.warn(`Failed to initialize offline executor:`, error)
280
+ span.setAttribute(`result`, `failed`)
281
+ this.initReject(
282
+ error instanceof Error ? error : new Error(String(error))
283
+ )
152
284
  }
153
285
  })
154
286
  }
155
287
 
156
288
  private async loadAndReplayTransactions(): Promise<void> {
289
+ if (!this.executor) {
290
+ return
291
+ }
292
+
157
293
  try {
158
294
  await this.executor.loadPendingTransactions()
159
295
  await this.executor.executeAll()
@@ -163,7 +299,7 @@ export class OfflineExecutor {
163
299
  }
164
300
 
165
301
  get isOfflineEnabled(): boolean {
166
- return this.isLeaderState
302
+ return this.mode === `offline` && this.isLeaderState
167
303
  }
168
304
 
169
305
  createOfflineTransaction(
@@ -237,6 +373,9 @@ export class OfflineExecutor {
237
373
  private async persistTransaction(
238
374
  transaction: OfflineTransaction
239
375
  ): Promise<void> {
376
+ // Wait for initialization to complete
377
+ await this.initPromise
378
+
240
379
  return withNestedSpan(
241
380
  `executor.persistTransaction`,
242
381
  {
@@ -244,7 +383,7 @@ export class OfflineExecutor {
244
383
  "transaction.mutationFnName": transaction.mutationFnName,
245
384
  },
246
385
  async (span) => {
247
- if (!this.isOfflineEnabled) {
386
+ if (!this.isOfflineEnabled || !this.outbox || !this.executor) {
248
387
  span.setAttribute(`result`, `skipped_not_leader`)
249
388
  this.resolveTransaction(transaction.id, undefined)
250
389
  return
@@ -307,14 +446,23 @@ export class OfflineExecutor {
307
446
  }
308
447
 
309
448
  async removeFromOutbox(id: string): Promise<void> {
449
+ if (!this.outbox) {
450
+ return
451
+ }
310
452
  await this.outbox.remove(id)
311
453
  }
312
454
 
313
455
  async peekOutbox(): Promise<Array<OfflineTransaction>> {
456
+ if (!this.outbox) {
457
+ return []
458
+ }
314
459
  return this.outbox.getAll()
315
460
  }
316
461
 
317
462
  async clearOutbox(): Promise<void> {
463
+ if (!this.outbox || !this.executor) {
464
+ return
465
+ }
318
466
  await this.outbox.clear()
319
467
  this.executor.clear()
320
468
  }
@@ -324,10 +472,16 @@ export class OfflineExecutor {
324
472
  }
325
473
 
326
474
  getPendingCount(): number {
475
+ if (!this.executor) {
476
+ return 0
477
+ }
327
478
  return this.executor.getPendingCount()
328
479
  }
329
480
 
330
481
  getRunningCount(): number {
482
+ if (!this.executor) {
483
+ return 0
484
+ }
331
485
  return this.executor.getRunningCount()
332
486
  }
333
487
 
@@ -346,12 +500,15 @@ export class OfflineExecutor {
346
500
  this.unsubscribeLeadership = null
347
501
  }
348
502
 
349
- this.leaderElection.releaseLeadership()
350
- this.onlineDetector.dispose()
503
+ if (this.leaderElection) {
504
+ this.leaderElection.releaseLeadership()
351
505
 
352
- if (`dispose` in this.leaderElection) {
353
- ;(this.leaderElection as any).dispose()
506
+ if (`dispose` in this.leaderElection) {
507
+ ;(this.leaderElection as any).dispose()
508
+ }
354
509
  }
510
+
511
+ this.onlineDetector.dispose()
355
512
  }
356
513
  }
357
514
 
@@ -1,4 +1,5 @@
1
1
  import { SpanStatusCode, context, trace } from "@opentelemetry/api"
2
+ import { OnMutateMustBeSynchronousError } from "@tanstack/db"
2
3
  import { OfflineTransaction } from "./OfflineTransaction"
3
4
  import type { Transaction } from "@tanstack/db"
4
5
  import type {
@@ -7,6 +8,14 @@ import type {
7
8
  OfflineTransaction as OfflineTransactionType,
8
9
  } from "../types"
9
10
 
11
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
12
+ return (
13
+ !!value &&
14
+ (typeof value === `object` || typeof value === `function`) &&
15
+ typeof (value as { then?: unknown }).then === `function`
16
+ )
17
+ }
18
+
10
19
  export function createOfflineAction<T>(
11
20
  options: CreateOfflineActionOptions<T>,
12
21
  mutationFn: OfflineMutationFn,
@@ -29,7 +38,11 @@ export function createOfflineAction<T>(
29
38
 
30
39
  const transaction = offlineTransaction.mutate(() => {
31
40
  console.log(`mutate`)
32
- onMutate(variables)
41
+ const maybePromise = onMutate(variables) as unknown
42
+
43
+ if (isPromiseLike(maybePromise)) {
44
+ throw new OnMutateMustBeSynchronousError()
45
+ }
33
46
  })
34
47
 
35
48
  // Immediately commit with span instrumentation
package/src/index.ts CHANGED
@@ -5,7 +5,10 @@ export { OfflineExecutor, startOfflineExecutor } from "./OfflineExecutor"
5
5
  export type {
6
6
  OfflineTransaction,
7
7
  OfflineConfig,
8
+ OfflineMode,
8
9
  StorageAdapter,
10
+ StorageDiagnostic,
11
+ StorageDiagnosticCode,
9
12
  RetryPolicy,
10
13
  LeaderElection,
11
14
  OnlineDetector,
@@ -11,6 +11,53 @@ export class IndexedDBAdapter extends BaseStorageAdapter {
11
11
  this.storeName = storeName
12
12
  }
13
13
 
14
+ /**
15
+ * Probe IndexedDB availability by attempting to open a test database.
16
+ * This catches private mode and other restrictions that block IndexedDB.
17
+ */
18
+ static async probe(): Promise<{ available: boolean; error?: Error }> {
19
+ // Check if IndexedDB exists
20
+ if (typeof indexedDB === `undefined`) {
21
+ return {
22
+ available: false,
23
+ error: new Error(`IndexedDB is not available in this environment`),
24
+ }
25
+ }
26
+
27
+ // Try to actually open a test database to verify it works
28
+ try {
29
+ const testDbName = `__offline-tx-probe__`
30
+ const request = indexedDB.open(testDbName, 1)
31
+
32
+ return new Promise((resolve) => {
33
+ request.onerror = () => {
34
+ const error = request.error || new Error(`IndexedDB open failed`)
35
+ resolve({ available: false, error })
36
+ }
37
+
38
+ request.onsuccess = () => {
39
+ // Clean up test database
40
+ const db = request.result
41
+ db.close()
42
+ indexedDB.deleteDatabase(testDbName)
43
+ resolve({ available: true })
44
+ }
45
+
46
+ request.onblocked = () => {
47
+ resolve({
48
+ available: false,
49
+ error: new Error(`IndexedDB is blocked`),
50
+ })
51
+ }
52
+ })
53
+ } catch (error) {
54
+ return {
55
+ available: false,
56
+ error: error instanceof Error ? error : new Error(String(error)),
57
+ }
58
+ }
59
+ }
60
+
14
61
  private async openDB(): Promise<IDBDatabase> {
15
62
  if (this.db) {
16
63
  return this.db