@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.
- package/LICENSE +21 -0
- package/dist/cjs/OfflineExecutor.cjs +141 -37
- package/dist/cjs/OfflineExecutor.cjs.map +1 -1
- package/dist/cjs/OfflineExecutor.d.cts +10 -1
- package/dist/cjs/api/OfflineAction.cjs +8 -1
- package/dist/cjs/api/OfflineAction.cjs.map +1 -1
- package/dist/cjs/index.d.cts +1 -1
- package/dist/cjs/storage/IndexedDBAdapter.cjs +39 -0
- package/dist/cjs/storage/IndexedDBAdapter.cjs.map +1 -1
- package/dist/cjs/storage/IndexedDBAdapter.d.cts +8 -0
- package/dist/cjs/storage/LocalStorageAdapter.cjs +31 -0
- package/dist/cjs/storage/LocalStorageAdapter.cjs.map +1 -1
- package/dist/cjs/storage/LocalStorageAdapter.d.cts +8 -0
- package/dist/cjs/telemetry/tracer.cjs +1 -1
- package/dist/cjs/telemetry/tracer.cjs.map +1 -1
- package/dist/cjs/types.cjs.map +1 -1
- package/dist/cjs/types.d.cts +9 -4
- package/dist/esm/OfflineExecutor.d.ts +10 -1
- package/dist/esm/OfflineExecutor.js +141 -37
- package/dist/esm/OfflineExecutor.js.map +1 -1
- package/dist/esm/api/OfflineAction.js +8 -1
- package/dist/esm/api/OfflineAction.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/storage/IndexedDBAdapter.d.ts +8 -0
- package/dist/esm/storage/IndexedDBAdapter.js +39 -0
- package/dist/esm/storage/IndexedDBAdapter.js.map +1 -1
- package/dist/esm/storage/LocalStorageAdapter.d.ts +8 -0
- package/dist/esm/storage/LocalStorageAdapter.js +31 -0
- package/dist/esm/storage/LocalStorageAdapter.js.map +1 -1
- package/dist/esm/telemetry/tracer.js +1 -1
- package/dist/esm/telemetry/tracer.js.map +1 -1
- package/dist/esm/types.d.ts +9 -4
- package/dist/esm/types.js.map +1 -1
- package/package.json +12 -12
- package/src/OfflineExecutor.ts +200 -43
- package/src/api/OfflineAction.ts +14 -1
- package/src/index.ts +3 -0
- package/src/storage/IndexedDBAdapter.ts +47 -0
- package/src/storage/LocalStorageAdapter.ts +38 -0
- package/src/telemetry/tracer.ts +4 -9
- 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(
|
|
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 {
|
|
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;"}
|
package/dist/esm/types.d.ts
CHANGED
|
@@ -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>;
|
package/dist/esm/types.js.map
CHANGED
|
@@ -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
|
|
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.
|
|
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": "
|
|
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": "
|
|
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
|
+
}
|
package/src/OfflineExecutor.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
|
350
|
-
|
|
503
|
+
if (this.leaderElection) {
|
|
504
|
+
this.leaderElection.releaseLeadership()
|
|
351
505
|
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
package/src/api/OfflineAction.ts
CHANGED
|
@@ -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
|