@cosmicdrift/kumiko-dispatcher-live 0.105.2 → 0.108.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dispatcher-live",
3
- "version": "0.105.2",
3
+ "version": "0.108.0",
4
4
  "description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -24,7 +24,7 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@cosmicdrift/kumiko-headless": "0.105.2"
27
+ "@cosmicdrift/kumiko-headless": "0.108.0"
28
28
  },
29
29
  "publishConfig": {
30
30
  "registry": "https://registry.npmjs.org",
@@ -46,7 +46,10 @@ describe("createLiveDispatcher", () => {
46
46
  expect(headers["Content-Type"]).toBe("application/json");
47
47
  expect(headers["X-CSRF-Token"]).toBe("csrf-abc");
48
48
  const body = JSON.parse(call?.init.body as string);
49
- expect(body).toEqual({ type: "app:write:task:create", payload: { title: "hello" } });
49
+ expect(body).toMatchObject({ type: "app:write:task:create", payload: { title: "hello" } });
50
+ // Auto-generated idempotency key (#761) — always present.
51
+ expect(typeof body.requestId).toBe("string");
52
+ expect(body.requestId.length).toBeGreaterThan(8);
50
53
  });
51
54
 
52
55
  test("write: propagates requestId into body when provided", async () => {
@@ -59,6 +62,43 @@ describe("createLiveDispatcher", () => {
59
62
  expect(body.requestId).toBe("idem-99");
60
63
  });
61
64
 
65
+ test("write: generates a fresh requestId per invocation when none provided (#761)", async () => {
66
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
67
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
68
+
69
+ await disp.write("app:write:x:create", { a: 1 });
70
+ await disp.write("app:write:x:create", { a: 1 });
71
+
72
+ const first = JSON.parse(calls[0]?.init.body as string).requestId;
73
+ const second = JSON.parse(calls[1]?.init.body as string).requestId;
74
+ expect(typeof first).toBe("string");
75
+ expect(typeof second).toBe("string");
76
+ expect(first).not.toBe(second);
77
+ });
78
+
79
+ test("batch: generates one requestId for the whole batch when none provided (#761)", async () => {
80
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
81
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
82
+
83
+ await disp.batch([
84
+ { type: "a", payload: {} },
85
+ { type: "b", payload: {} },
86
+ ]);
87
+
88
+ const body = JSON.parse(calls[0]?.init.body as string);
89
+ expect(typeof body.requestId).toBe("string");
90
+ });
91
+
92
+ test("batch: propagates an explicit requestId (#761)", async () => {
93
+ const { fetch, calls } = makeFetch({ body: { isSuccess: true, results: [] } });
94
+ const disp = createLiveDispatcher({ fetch, readCsrf: () => "t" });
95
+
96
+ await disp.batch([{ type: "a", payload: {} }], { requestId: "batch-7" });
97
+
98
+ const body = JSON.parse(calls[0]?.init.body as string);
99
+ expect(body.requestId).toBe("batch-7");
100
+ });
101
+
62
102
  test("write: no CSRF token → header omitted, request still fires (server will 401/csrf-mismatch)", async () => {
63
103
  const { fetch, calls } = makeFetch({ body: { isSuccess: true, data: {} } });
64
104
  const disp = createLiveDispatcher({ fetch, readCsrf: () => undefined });
@@ -154,7 +154,11 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
154
154
  opts?: WriteOpts,
155
155
  ): Promise<WriteResult<TData>> {
156
156
  const body: Record<string, unknown> = { type, payload };
157
- if (opts?.requestId) body["requestId"] = opts.requestId;
157
+ // Idempotency by default (#761): without a requestId the server-side
158
+ // dedup never engages and a transport-level double-send duplicates
159
+ // events. Callers with a logical-submit id (savable queue, form
160
+ // controllers) pass their own and keep it across their retries.
161
+ body["requestId"] = opts?.requestId ?? generateRequestId();
158
162
  const call = await callJson(PATH_WRITE, body, opts?.signal);
159
163
  return normalizeWriteResult<TData>(call);
160
164
  },
@@ -171,7 +175,10 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
171
175
 
172
176
  async batch(commands: readonly Command[], opts?: WriteOpts): Promise<BatchResult> {
173
177
  const body: Record<string, unknown> = { commands };
174
- if (opts?.requestId) body["requestId"] = opts.requestId;
178
+ // One id for the whole batch — the server caches the BatchResult
179
+ // under it, so a retried batch returns the cached outcome instead of
180
+ // re-executing the commands (#761).
181
+ body["requestId"] = opts?.requestId ?? generateRequestId();
175
182
  const call = await callJson(PATH_BATCH, body, opts?.signal);
176
183
  return normalizeBatchResponse(call);
177
184
  },
@@ -189,6 +196,16 @@ export function createLiveDispatcher(options: LiveDispatcherOptions = {}): Dispa
189
196
  const EMPTY_PENDING_WRITES: readonly PendingWrite[] = Object.freeze([]);
190
197
  const EMPTY_PENDING_FILES: readonly PendingFile[] = Object.freeze([]);
191
198
 
199
+ // crypto.randomUUID where available (browser, Bun, Node); Math.random
200
+ // fallback for React-Native runtimes without the WebCrypto polyfill.
201
+ // Uniqueness only needs to hold per user within the server's dedup window —
202
+ // this is an idempotency key, not a security token.
203
+ function generateRequestId(): string {
204
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
205
+ if (typeof c?.randomUUID === "function") return c.randomUUID();
206
+ return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
207
+ }
208
+
192
209
  type CallOutcome =
193
210
  | { readonly ok: true; readonly body: unknown; readonly status: number }
194
211
  | { readonly ok: false; readonly networkFailure: DispatcherError };