@porulle/sdk 0.7.0 → 0.8.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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/offline-queue.d.ts +105 -0
- package/dist/offline-queue.d.ts.map +1 -0
- package/dist/offline-queue.js +228 -0
- package/dist/offline-queue.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +9 -0
- package/src/offline-queue.ts +286 -0
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createSDK, createClient, type SDKOptions } from "./client.js";
|
|
2
|
+
export { OfflineQueue, memoryStorage, webStorage, type OfflineQueueOptions, type QueuedOperation, type QueueState, type QueueStorage, } from "./offline-queue.js";
|
|
2
3
|
export { authMiddleware, type AuthCredential, type ApiKeyAuth, type BearerAuth } from "./middleware.js";
|
|
3
4
|
export { isApiError, mapApiErrorToFields, type ApiErrorBody, type ValidationIssue, } from "./errors.js";
|
|
4
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACL,UAAU,EACV,mBAAmB,EACnB,KAAK,YAAY,EACjB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,EACL,YAAY,EACZ,aAAa,EACb,UAAU,EACV,KAAK,mBAAmB,EACxB,KAAK,eAAe,EACpB,KAAK,UAAU,EACf,KAAK,YAAY,GAClB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,KAAK,cAAc,EAAE,KAAK,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACL,UAAU,EACV,mBAAmB,EACnB,KAAK,YAAY,EACjB,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createSDK, createClient } from "./client.js";
|
|
2
|
+
export { OfflineQueue, memoryStorage, webStorage, } from "./offline-queue.js";
|
|
2
3
|
export { authMiddleware } from "./middleware.js";
|
|
3
4
|
export { isApiError, mapApiErrorToFields, } from "./errors.js";
|
|
4
5
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,YAAY,EAAmB,MAAM,aAAa,CAAC;AACvE,OAAO,EAAE,cAAc,EAAyD,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACL,UAAU,EACV,mBAAmB,GAGpB,MAAM,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,YAAY,EAAmB,MAAM,aAAa,CAAC;AACvE,OAAO,EACL,YAAY,EACZ,aAAa,EACb,UAAU,GAKX,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAyD,MAAM,iBAAiB,CAAC;AACxG,OAAO,EACL,UAAU,EACV,mBAAmB,GAGpB,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-first operation queue (issue #54).
|
|
3
|
+
*
|
|
4
|
+
* POS clients must keep selling through network drops. This queue persists
|
|
5
|
+
* write operations (pluggable storage), stamps each with an idempotency key
|
|
6
|
+
* (core replays `POST /api/orders` / `POST /api/checkout` with the same key
|
|
7
|
+
* and returns the original order), and drains on reconnect with backoff.
|
|
8
|
+
* Queue state — pending/failed plus server error bodies — is observable, and
|
|
9
|
+
* failed operations support manual retry/drop.
|
|
10
|
+
*/
|
|
11
|
+
export interface QueuedOperation {
|
|
12
|
+
/** Unique id; also stamped into the body as `idempotencyKey`. */
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
16
|
+
body: Record<string, unknown>;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
attempts: number;
|
|
19
|
+
status: "pending" | "failed";
|
|
20
|
+
lastError?: {
|
|
21
|
+
status?: number | undefined;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
message: string;
|
|
24
|
+
} | undefined;
|
|
25
|
+
}
|
|
26
|
+
export interface QueueStorage {
|
|
27
|
+
load(): Promise<QueuedOperation[]> | QueuedOperation[];
|
|
28
|
+
save(operations: QueuedOperation[]): Promise<void> | void;
|
|
29
|
+
}
|
|
30
|
+
/** Volatile storage — tests and environments without persistence. */
|
|
31
|
+
export declare function memoryStorage(): QueueStorage;
|
|
32
|
+
/** Storage backed by any localStorage-compatible object (web, RN shims). */
|
|
33
|
+
export declare function webStorage(storage: {
|
|
34
|
+
getItem(key: string): string | null;
|
|
35
|
+
setItem(key: string, value: string): void;
|
|
36
|
+
}, key?: string): QueueStorage;
|
|
37
|
+
export interface OfflineQueueOptions {
|
|
38
|
+
/** Base URL of the commerce server (e.g. "https://api.example.com"). */
|
|
39
|
+
baseUrl: string;
|
|
40
|
+
/** Headers sent with every replayed request (e.g. Authorization). */
|
|
41
|
+
headers?: Record<string, string> | undefined;
|
|
42
|
+
/** Where operations persist. Default: in-memory. */
|
|
43
|
+
storage?: QueueStorage | undefined;
|
|
44
|
+
/** Custom fetch (testing, RN). Default: globalThis.fetch. */
|
|
45
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
46
|
+
/** Attempts before a retryable failure is marked `failed`. Default: 5. */
|
|
47
|
+
maxAttempts?: number | undefined;
|
|
48
|
+
/** Base backoff delay in ms (doubles per attempt, capped at 60s). Default: 1000. */
|
|
49
|
+
backoffMs?: number | undefined;
|
|
50
|
+
/** Listen for the window `online` event and auto-flush. Default: true when available. */
|
|
51
|
+
autoFlush?: boolean | undefined;
|
|
52
|
+
/** Field name stamped into the body. Default: "idempotencyKey". */
|
|
53
|
+
idempotencyField?: string | undefined;
|
|
54
|
+
}
|
|
55
|
+
export interface QueueState {
|
|
56
|
+
pending: number;
|
|
57
|
+
failed: number;
|
|
58
|
+
operations: QueuedOperation[];
|
|
59
|
+
}
|
|
60
|
+
type Listener = (state: QueueState) => void;
|
|
61
|
+
export declare class OfflineQueue {
|
|
62
|
+
private readonly baseUrl;
|
|
63
|
+
private readonly headers;
|
|
64
|
+
private readonly fetchImpl;
|
|
65
|
+
private readonly maxAttempts;
|
|
66
|
+
private readonly backoffMs;
|
|
67
|
+
private readonly idempotencyField;
|
|
68
|
+
private readonly storage;
|
|
69
|
+
private operations;
|
|
70
|
+
private listeners;
|
|
71
|
+
private loaded;
|
|
72
|
+
private flushing;
|
|
73
|
+
private retryTimer;
|
|
74
|
+
private readonly onlineHandler;
|
|
75
|
+
constructor(options: OfflineQueueOptions);
|
|
76
|
+
/** Stops timers and the online listener. */
|
|
77
|
+
dispose(): void;
|
|
78
|
+
/**
|
|
79
|
+
* Enqueues an operation with an idempotency key stamped into the body.
|
|
80
|
+
* Resolves once the operation is durably queued — call `flush()` (or rely
|
|
81
|
+
* on the `online` listener / backoff timer) to deliver it.
|
|
82
|
+
*/
|
|
83
|
+
enqueue(path: string, body: Record<string, unknown>, opts?: {
|
|
84
|
+
method?: QueuedOperation["method"] | undefined;
|
|
85
|
+
idempotencyKey?: string | undefined;
|
|
86
|
+
}): Promise<QueuedOperation>;
|
|
87
|
+
/** Current queue state (pending/failed counts + operations with errors). */
|
|
88
|
+
state(): Promise<QueueState>;
|
|
89
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
90
|
+
onChange(listener: Listener): () => void;
|
|
91
|
+
/** Re-queues a failed operation for the next flush. */
|
|
92
|
+
retry(id: string): Promise<void>;
|
|
93
|
+
/** Drops an operation from the queue entirely. */
|
|
94
|
+
drop(id: string): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Drains pending operations FIFO. Network-level failures stop the drain
|
|
97
|
+
* (we're offline) and schedule a backoff retry; HTTP failures mark the
|
|
98
|
+
* operation failed (non-retryable) or retry it (5xx/408/429).
|
|
99
|
+
*/
|
|
100
|
+
flush(): Promise<QueueState>;
|
|
101
|
+
private scheduleRetry;
|
|
102
|
+
private persist;
|
|
103
|
+
}
|
|
104
|
+
export {};
|
|
105
|
+
//# sourceMappingURL=offline-queue.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offline-queue.d.ts","sourceRoot":"","sources":["../src/offline-queue.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC7B,SAAS,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;QAAC,IAAI,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CAC1F;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC,GAAG,eAAe,EAAE,CAAC;IACvD,IAAI,CAAC,UAAU,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CAC3D;AAED,qEAAqE;AACrE,wBAAgB,aAAa,IAAI,YAAY,CAQ5C;AAED,4EAA4E;AAC5E,wBAAgB,UAAU,CACxB,OAAO,EAAE;IAAE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EAC3F,GAAG,SAA0B,GAC5B,YAAY,CAcd;AAED,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAAC;IAC7C,oDAAoD;IACpD,OAAO,CAAC,EAAE,YAAY,GAAG,SAAS,CAAC;IACnC,6DAA6D;IAC7D,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,GAAG,SAAS,CAAC;IAC5C,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,yFAAyF;IACzF,SAAS,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAChC,mEAAmE;IACnE,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACvC;AAED,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,eAAe,EAAE,CAAC;CAC/B;AAED,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;AAc5C,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqC;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsC;IAChE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,UAAU,CAAyB;IAC3C,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,MAAM,CAAgB;IAC9B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAA4C;IAC9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAE5B;gBAEU,OAAO,EAAE,mBAAmB;IAkBxC,4CAA4C;IAC5C,OAAO,IAAI,IAAI;IAQf;;;;OAIG;IACG,OAAO,CACX,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,eAAe,CAAC,QAAQ,CAAC,GAAG,SAAS,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;KAAE,GAC7F,OAAO,CAAC,eAAe,CAAC;IAiB3B,4EAA4E;IACtE,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IASlC,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,IAAI;IAKxC,uDAAuD;IACjD,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYtC,kDAAkD;IAC5C,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMrC;;;;OAIG;IACG,KAAK,IAAI,OAAO,CAAC,UAAU,CAAC;IAwDlC,OAAO,CAAC,aAAa;YAWP,OAAO;CAStB"}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-first operation queue (issue #54).
|
|
3
|
+
*
|
|
4
|
+
* POS clients must keep selling through network drops. This queue persists
|
|
5
|
+
* write operations (pluggable storage), stamps each with an idempotency key
|
|
6
|
+
* (core replays `POST /api/orders` / `POST /api/checkout` with the same key
|
|
7
|
+
* and returns the original order), and drains on reconnect with backoff.
|
|
8
|
+
* Queue state — pending/failed plus server error bodies — is observable, and
|
|
9
|
+
* failed operations support manual retry/drop.
|
|
10
|
+
*/
|
|
11
|
+
/** Volatile storage — tests and environments without persistence. */
|
|
12
|
+
export function memoryStorage() {
|
|
13
|
+
let operations = [];
|
|
14
|
+
return {
|
|
15
|
+
load: () => operations,
|
|
16
|
+
save: (next) => {
|
|
17
|
+
operations = next;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Storage backed by any localStorage-compatible object (web, RN shims). */
|
|
22
|
+
export function webStorage(storage, key = "porulle.offline-queue") {
|
|
23
|
+
return {
|
|
24
|
+
load: () => {
|
|
25
|
+
try {
|
|
26
|
+
const raw = storage.getItem(key);
|
|
27
|
+
return raw ? JSON.parse(raw) : [];
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
save: (operations) => {
|
|
34
|
+
storage.setItem(key, JSON.stringify(operations));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function generateId() {
|
|
39
|
+
const cryptoObj = globalThis.crypto;
|
|
40
|
+
if (cryptoObj?.randomUUID)
|
|
41
|
+
return cryptoObj.randomUUID();
|
|
42
|
+
return `op_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
43
|
+
}
|
|
44
|
+
// 408 (timeout) and 429 (rate limit) are worth retrying; other 4xx are the
|
|
45
|
+
// server telling us the operation itself is wrong.
|
|
46
|
+
function isRetryableStatus(status) {
|
|
47
|
+
return status >= 500 || status === 408 || status === 429;
|
|
48
|
+
}
|
|
49
|
+
export class OfflineQueue {
|
|
50
|
+
baseUrl;
|
|
51
|
+
headers;
|
|
52
|
+
fetchImpl;
|
|
53
|
+
maxAttempts;
|
|
54
|
+
backoffMs;
|
|
55
|
+
idempotencyField;
|
|
56
|
+
storage;
|
|
57
|
+
operations = [];
|
|
58
|
+
listeners = new Set();
|
|
59
|
+
loaded;
|
|
60
|
+
flushing = false;
|
|
61
|
+
retryTimer;
|
|
62
|
+
onlineHandler = () => {
|
|
63
|
+
void this.flush();
|
|
64
|
+
};
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.baseUrl = options.baseUrl;
|
|
67
|
+
this.headers = options.headers;
|
|
68
|
+
this.fetchImpl = options.fetch;
|
|
69
|
+
this.maxAttempts = options.maxAttempts ?? 5;
|
|
70
|
+
this.backoffMs = options.backoffMs ?? 1000;
|
|
71
|
+
this.idempotencyField = options.idempotencyField ?? "idempotencyKey";
|
|
72
|
+
this.storage = options.storage ?? memoryStorage();
|
|
73
|
+
this.loaded = Promise.resolve(this.storage.load()).then((ops) => {
|
|
74
|
+
this.operations = ops;
|
|
75
|
+
});
|
|
76
|
+
const target = globalThis;
|
|
77
|
+
if ((options.autoFlush ?? true) && typeof target.addEventListener === "function") {
|
|
78
|
+
target.addEventListener("online", this.onlineHandler);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Stops timers and the online listener. */
|
|
82
|
+
dispose() {
|
|
83
|
+
if (this.retryTimer !== undefined)
|
|
84
|
+
clearTimeout(this.retryTimer);
|
|
85
|
+
const target = globalThis;
|
|
86
|
+
if (typeof target.removeEventListener === "function") {
|
|
87
|
+
target.removeEventListener("online", this.onlineHandler);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Enqueues an operation with an idempotency key stamped into the body.
|
|
92
|
+
* Resolves once the operation is durably queued — call `flush()` (or rely
|
|
93
|
+
* on the `online` listener / backoff timer) to deliver it.
|
|
94
|
+
*/
|
|
95
|
+
async enqueue(path, body, opts) {
|
|
96
|
+
await this.loaded;
|
|
97
|
+
const id = opts?.idempotencyKey ?? generateId();
|
|
98
|
+
const operation = {
|
|
99
|
+
id,
|
|
100
|
+
path,
|
|
101
|
+
method: opts?.method ?? "POST",
|
|
102
|
+
body: { ...body, [this.idempotencyField]: id },
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
attempts: 0,
|
|
105
|
+
status: "pending",
|
|
106
|
+
};
|
|
107
|
+
this.operations.push(operation);
|
|
108
|
+
await this.persist();
|
|
109
|
+
return operation;
|
|
110
|
+
}
|
|
111
|
+
/** Current queue state (pending/failed counts + operations with errors). */
|
|
112
|
+
async state() {
|
|
113
|
+
await this.loaded;
|
|
114
|
+
return {
|
|
115
|
+
pending: this.operations.filter((op) => op.status === "pending").length,
|
|
116
|
+
failed: this.operations.filter((op) => op.status === "failed").length,
|
|
117
|
+
operations: [...this.operations],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
121
|
+
onChange(listener) {
|
|
122
|
+
this.listeners.add(listener);
|
|
123
|
+
return () => this.listeners.delete(listener);
|
|
124
|
+
}
|
|
125
|
+
/** Re-queues a failed operation for the next flush. */
|
|
126
|
+
async retry(id) {
|
|
127
|
+
await this.loaded;
|
|
128
|
+
const operation = this.operations.find((op) => op.id === id);
|
|
129
|
+
if (operation && operation.status === "failed") {
|
|
130
|
+
operation.status = "pending";
|
|
131
|
+
operation.attempts = 0;
|
|
132
|
+
operation.lastError = undefined;
|
|
133
|
+
await this.persist();
|
|
134
|
+
void this.flush();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Drops an operation from the queue entirely. */
|
|
138
|
+
async drop(id) {
|
|
139
|
+
await this.loaded;
|
|
140
|
+
this.operations = this.operations.filter((op) => op.id !== id);
|
|
141
|
+
await this.persist();
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Drains pending operations FIFO. Network-level failures stop the drain
|
|
145
|
+
* (we're offline) and schedule a backoff retry; HTTP failures mark the
|
|
146
|
+
* operation failed (non-retryable) or retry it (5xx/408/429).
|
|
147
|
+
*/
|
|
148
|
+
async flush() {
|
|
149
|
+
await this.loaded;
|
|
150
|
+
if (this.flushing)
|
|
151
|
+
return this.state();
|
|
152
|
+
this.flushing = true;
|
|
153
|
+
try {
|
|
154
|
+
const fetchImpl = this.fetchImpl ?? globalThis.fetch;
|
|
155
|
+
for (const operation of [...this.operations]) {
|
|
156
|
+
if (operation.status !== "pending")
|
|
157
|
+
continue;
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(`${this.baseUrl}${operation.path}`, {
|
|
161
|
+
method: operation.method,
|
|
162
|
+
headers: { "content-type": "application/json", ...this.headers },
|
|
163
|
+
body: JSON.stringify(operation.body),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
// Network down — keep everything pending and back off.
|
|
168
|
+
operation.attempts += 1;
|
|
169
|
+
operation.lastError = { message: error instanceof Error ? error.message : String(error) };
|
|
170
|
+
await this.persist();
|
|
171
|
+
this.scheduleRetry(operation.attempts);
|
|
172
|
+
return this.state();
|
|
173
|
+
}
|
|
174
|
+
if (response.ok) {
|
|
175
|
+
this.operations = this.operations.filter((op) => op.id !== operation.id);
|
|
176
|
+
await this.persist();
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
operation.attempts += 1;
|
|
180
|
+
let errorBody;
|
|
181
|
+
try {
|
|
182
|
+
errorBody = await response.clone().json();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
errorBody = undefined;
|
|
186
|
+
}
|
|
187
|
+
operation.lastError = {
|
|
188
|
+
status: response.status,
|
|
189
|
+
body: errorBody,
|
|
190
|
+
message: `HTTP ${response.status}`,
|
|
191
|
+
};
|
|
192
|
+
if (!isRetryableStatus(response.status) || operation.attempts >= this.maxAttempts) {
|
|
193
|
+
operation.status = "failed";
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
this.scheduleRetry(operation.attempts);
|
|
197
|
+
}
|
|
198
|
+
await this.persist();
|
|
199
|
+
}
|
|
200
|
+
return this.state();
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
this.flushing = false;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
scheduleRetry(attempts) {
|
|
207
|
+
if (this.retryTimer !== undefined)
|
|
208
|
+
clearTimeout(this.retryTimer);
|
|
209
|
+
const delay = Math.min(this.backoffMs * 2 ** (attempts - 1), 60_000);
|
|
210
|
+
this.retryTimer = setTimeout(() => {
|
|
211
|
+
this.retryTimer = undefined;
|
|
212
|
+
void this.flush();
|
|
213
|
+
}, delay);
|
|
214
|
+
// Never keep a Node.js process alive just for a queue retry.
|
|
215
|
+
this.retryTimer.unref?.();
|
|
216
|
+
}
|
|
217
|
+
async persist() {
|
|
218
|
+
await this.storage.save([...this.operations]);
|
|
219
|
+
const state = {
|
|
220
|
+
pending: this.operations.filter((op) => op.status === "pending").length,
|
|
221
|
+
failed: this.operations.filter((op) => op.status === "failed").length,
|
|
222
|
+
operations: [...this.operations],
|
|
223
|
+
};
|
|
224
|
+
for (const listener of this.listeners)
|
|
225
|
+
listener(state);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=offline-queue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"offline-queue.js","sourceRoot":"","sources":["../src/offline-queue.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
export { createSDK, createClient, type SDKOptions } from "./client.js";
|
|
2
|
+
export {
|
|
3
|
+
OfflineQueue,
|
|
4
|
+
memoryStorage,
|
|
5
|
+
webStorage,
|
|
6
|
+
type OfflineQueueOptions,
|
|
7
|
+
type QueuedOperation,
|
|
8
|
+
type QueueState,
|
|
9
|
+
type QueueStorage,
|
|
10
|
+
} from "./offline-queue.js";
|
|
2
11
|
export { authMiddleware, type AuthCredential, type ApiKeyAuth, type BearerAuth } from "./middleware.js";
|
|
3
12
|
export {
|
|
4
13
|
isApiError,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline-first operation queue (issue #54).
|
|
3
|
+
*
|
|
4
|
+
* POS clients must keep selling through network drops. This queue persists
|
|
5
|
+
* write operations (pluggable storage), stamps each with an idempotency key
|
|
6
|
+
* (core replays `POST /api/orders` / `POST /api/checkout` with the same key
|
|
7
|
+
* and returns the original order), and drains on reconnect with backoff.
|
|
8
|
+
* Queue state — pending/failed plus server error bodies — is observable, and
|
|
9
|
+
* failed operations support manual retry/drop.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface QueuedOperation {
|
|
13
|
+
/** Unique id; also stamped into the body as `idempotencyKey`. */
|
|
14
|
+
id: string;
|
|
15
|
+
path: string;
|
|
16
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE";
|
|
17
|
+
body: Record<string, unknown>;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
attempts: number;
|
|
20
|
+
status: "pending" | "failed";
|
|
21
|
+
lastError?: { status?: number | undefined; body?: unknown; message: string } | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QueueStorage {
|
|
25
|
+
load(): Promise<QueuedOperation[]> | QueuedOperation[];
|
|
26
|
+
save(operations: QueuedOperation[]): Promise<void> | void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Volatile storage — tests and environments without persistence. */
|
|
30
|
+
export function memoryStorage(): QueueStorage {
|
|
31
|
+
let operations: QueuedOperation[] = [];
|
|
32
|
+
return {
|
|
33
|
+
load: () => operations,
|
|
34
|
+
save: (next) => {
|
|
35
|
+
operations = next;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Storage backed by any localStorage-compatible object (web, RN shims). */
|
|
41
|
+
export function webStorage(
|
|
42
|
+
storage: { getItem(key: string): string | null; setItem(key: string, value: string): void },
|
|
43
|
+
key = "porulle.offline-queue",
|
|
44
|
+
): QueueStorage {
|
|
45
|
+
return {
|
|
46
|
+
load: () => {
|
|
47
|
+
try {
|
|
48
|
+
const raw = storage.getItem(key);
|
|
49
|
+
return raw ? (JSON.parse(raw) as QueuedOperation[]) : [];
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
save: (operations) => {
|
|
55
|
+
storage.setItem(key, JSON.stringify(operations));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OfflineQueueOptions {
|
|
61
|
+
/** Base URL of the commerce server (e.g. "https://api.example.com"). */
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
/** Headers sent with every replayed request (e.g. Authorization). */
|
|
64
|
+
headers?: Record<string, string> | undefined;
|
|
65
|
+
/** Where operations persist. Default: in-memory. */
|
|
66
|
+
storage?: QueueStorage | undefined;
|
|
67
|
+
/** Custom fetch (testing, RN). Default: globalThis.fetch. */
|
|
68
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
69
|
+
/** Attempts before a retryable failure is marked `failed`. Default: 5. */
|
|
70
|
+
maxAttempts?: number | undefined;
|
|
71
|
+
/** Base backoff delay in ms (doubles per attempt, capped at 60s). Default: 1000. */
|
|
72
|
+
backoffMs?: number | undefined;
|
|
73
|
+
/** Listen for the window `online` event and auto-flush. Default: true when available. */
|
|
74
|
+
autoFlush?: boolean | undefined;
|
|
75
|
+
/** Field name stamped into the body. Default: "idempotencyKey". */
|
|
76
|
+
idempotencyField?: string | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface QueueState {
|
|
80
|
+
pending: number;
|
|
81
|
+
failed: number;
|
|
82
|
+
operations: QueuedOperation[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Listener = (state: QueueState) => void;
|
|
86
|
+
|
|
87
|
+
function generateId(): string {
|
|
88
|
+
const cryptoObj = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
89
|
+
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
|
|
90
|
+
return `op_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 408 (timeout) and 429 (rate limit) are worth retrying; other 4xx are the
|
|
94
|
+
// server telling us the operation itself is wrong.
|
|
95
|
+
function isRetryableStatus(status: number): boolean {
|
|
96
|
+
return status >= 500 || status === 408 || status === 429;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class OfflineQueue {
|
|
100
|
+
private readonly baseUrl: string;
|
|
101
|
+
private readonly headers: Record<string, string> | undefined;
|
|
102
|
+
private readonly fetchImpl: typeof globalThis.fetch | undefined;
|
|
103
|
+
private readonly maxAttempts: number;
|
|
104
|
+
private readonly backoffMs: number;
|
|
105
|
+
private readonly idempotencyField: string;
|
|
106
|
+
private readonly storage: QueueStorage;
|
|
107
|
+
private operations: QueuedOperation[] = [];
|
|
108
|
+
private listeners = new Set<Listener>();
|
|
109
|
+
private loaded: Promise<void>;
|
|
110
|
+
private flushing = false;
|
|
111
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
private readonly onlineHandler = () => {
|
|
113
|
+
void this.flush();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
constructor(options: OfflineQueueOptions) {
|
|
117
|
+
this.baseUrl = options.baseUrl;
|
|
118
|
+
this.headers = options.headers;
|
|
119
|
+
this.fetchImpl = options.fetch;
|
|
120
|
+
this.maxAttempts = options.maxAttempts ?? 5;
|
|
121
|
+
this.backoffMs = options.backoffMs ?? 1000;
|
|
122
|
+
this.idempotencyField = options.idempotencyField ?? "idempotencyKey";
|
|
123
|
+
this.storage = options.storage ?? memoryStorage();
|
|
124
|
+
this.loaded = Promise.resolve(this.storage.load()).then((ops) => {
|
|
125
|
+
this.operations = ops;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const target = (globalThis as { addEventListener?: (t: string, h: () => void) => void });
|
|
129
|
+
if ((options.autoFlush ?? true) && typeof target.addEventListener === "function") {
|
|
130
|
+
target.addEventListener("online", this.onlineHandler);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Stops timers and the online listener. */
|
|
135
|
+
dispose(): void {
|
|
136
|
+
if (this.retryTimer !== undefined) clearTimeout(this.retryTimer);
|
|
137
|
+
const target = (globalThis as { removeEventListener?: (t: string, h: () => void) => void });
|
|
138
|
+
if (typeof target.removeEventListener === "function") {
|
|
139
|
+
target.removeEventListener("online", this.onlineHandler);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Enqueues an operation with an idempotency key stamped into the body.
|
|
145
|
+
* Resolves once the operation is durably queued — call `flush()` (or rely
|
|
146
|
+
* on the `online` listener / backoff timer) to deliver it.
|
|
147
|
+
*/
|
|
148
|
+
async enqueue(
|
|
149
|
+
path: string,
|
|
150
|
+
body: Record<string, unknown>,
|
|
151
|
+
opts?: { method?: QueuedOperation["method"] | undefined; idempotencyKey?: string | undefined },
|
|
152
|
+
): Promise<QueuedOperation> {
|
|
153
|
+
await this.loaded;
|
|
154
|
+
const id = opts?.idempotencyKey ?? generateId();
|
|
155
|
+
const operation: QueuedOperation = {
|
|
156
|
+
id,
|
|
157
|
+
path,
|
|
158
|
+
method: opts?.method ?? "POST",
|
|
159
|
+
body: { ...body, [this.idempotencyField]: id },
|
|
160
|
+
createdAt: new Date().toISOString(),
|
|
161
|
+
attempts: 0,
|
|
162
|
+
status: "pending",
|
|
163
|
+
};
|
|
164
|
+
this.operations.push(operation);
|
|
165
|
+
await this.persist();
|
|
166
|
+
return operation;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Current queue state (pending/failed counts + operations with errors). */
|
|
170
|
+
async state(): Promise<QueueState> {
|
|
171
|
+
await this.loaded;
|
|
172
|
+
return {
|
|
173
|
+
pending: this.operations.filter((op) => op.status === "pending").length,
|
|
174
|
+
failed: this.operations.filter((op) => op.status === "failed").length,
|
|
175
|
+
operations: [...this.operations],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
180
|
+
onChange(listener: Listener): () => void {
|
|
181
|
+
this.listeners.add(listener);
|
|
182
|
+
return () => this.listeners.delete(listener);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Re-queues a failed operation for the next flush. */
|
|
186
|
+
async retry(id: string): Promise<void> {
|
|
187
|
+
await this.loaded;
|
|
188
|
+
const operation = this.operations.find((op) => op.id === id);
|
|
189
|
+
if (operation && operation.status === "failed") {
|
|
190
|
+
operation.status = "pending";
|
|
191
|
+
operation.attempts = 0;
|
|
192
|
+
operation.lastError = undefined;
|
|
193
|
+
await this.persist();
|
|
194
|
+
void this.flush();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drops an operation from the queue entirely. */
|
|
199
|
+
async drop(id: string): Promise<void> {
|
|
200
|
+
await this.loaded;
|
|
201
|
+
this.operations = this.operations.filter((op) => op.id !== id);
|
|
202
|
+
await this.persist();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Drains pending operations FIFO. Network-level failures stop the drain
|
|
207
|
+
* (we're offline) and schedule a backoff retry; HTTP failures mark the
|
|
208
|
+
* operation failed (non-retryable) or retry it (5xx/408/429).
|
|
209
|
+
*/
|
|
210
|
+
async flush(): Promise<QueueState> {
|
|
211
|
+
await this.loaded;
|
|
212
|
+
if (this.flushing) return this.state();
|
|
213
|
+
this.flushing = true;
|
|
214
|
+
try {
|
|
215
|
+
const fetchImpl = this.fetchImpl ?? globalThis.fetch;
|
|
216
|
+
for (const operation of [...this.operations]) {
|
|
217
|
+
if (operation.status !== "pending") continue;
|
|
218
|
+
|
|
219
|
+
let response: Response;
|
|
220
|
+
try {
|
|
221
|
+
response = await fetchImpl(`${this.baseUrl}${operation.path}`, {
|
|
222
|
+
method: operation.method,
|
|
223
|
+
headers: { "content-type": "application/json", ...this.headers },
|
|
224
|
+
body: JSON.stringify(operation.body),
|
|
225
|
+
});
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Network down — keep everything pending and back off.
|
|
228
|
+
operation.attempts += 1;
|
|
229
|
+
operation.lastError = { message: error instanceof Error ? error.message : String(error) };
|
|
230
|
+
await this.persist();
|
|
231
|
+
this.scheduleRetry(operation.attempts);
|
|
232
|
+
return this.state();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (response.ok) {
|
|
236
|
+
this.operations = this.operations.filter((op) => op.id !== operation.id);
|
|
237
|
+
await this.persist();
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
operation.attempts += 1;
|
|
242
|
+
let errorBody: unknown;
|
|
243
|
+
try {
|
|
244
|
+
errorBody = await response.clone().json();
|
|
245
|
+
} catch {
|
|
246
|
+
errorBody = undefined;
|
|
247
|
+
}
|
|
248
|
+
operation.lastError = {
|
|
249
|
+
status: response.status,
|
|
250
|
+
body: errorBody,
|
|
251
|
+
message: `HTTP ${response.status}`,
|
|
252
|
+
};
|
|
253
|
+
if (!isRetryableStatus(response.status) || operation.attempts >= this.maxAttempts) {
|
|
254
|
+
operation.status = "failed";
|
|
255
|
+
} else {
|
|
256
|
+
this.scheduleRetry(operation.attempts);
|
|
257
|
+
}
|
|
258
|
+
await this.persist();
|
|
259
|
+
}
|
|
260
|
+
return this.state();
|
|
261
|
+
} finally {
|
|
262
|
+
this.flushing = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private scheduleRetry(attempts: number): void {
|
|
267
|
+
if (this.retryTimer !== undefined) clearTimeout(this.retryTimer);
|
|
268
|
+
const delay = Math.min(this.backoffMs * 2 ** (attempts - 1), 60_000);
|
|
269
|
+
this.retryTimer = setTimeout(() => {
|
|
270
|
+
this.retryTimer = undefined;
|
|
271
|
+
void this.flush();
|
|
272
|
+
}, delay);
|
|
273
|
+
// Never keep a Node.js process alive just for a queue retry.
|
|
274
|
+
(this.retryTimer as { unref?: () => void }).unref?.();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private async persist(): Promise<void> {
|
|
278
|
+
await this.storage.save([...this.operations]);
|
|
279
|
+
const state: QueueState = {
|
|
280
|
+
pending: this.operations.filter((op) => op.status === "pending").length,
|
|
281
|
+
failed: this.operations.filter((op) => op.status === "failed").length,
|
|
282
|
+
operations: [...this.operations],
|
|
283
|
+
};
|
|
284
|
+
for (const listener of this.listeners) listener(state);
|
|
285
|
+
}
|
|
286
|
+
}
|