@pylonsync/sync 0.22.47 → 0.22.50

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.
@@ -0,0 +1,110 @@
1
+ // What happens today when two clients edit the same row while offline and
2
+ // then reconnect (Plan 013 step 5). This records the behavior; it does not
3
+ // change it.
4
+ //
5
+ // Result: the server applies each update as a field-level patch in the order
6
+ // the pushes arrive. Different fields both survive. On the same field the
7
+ // last push wins. Both pushes come back "applied", so neither client is told
8
+ // that another edit replaced its value: the earlier writer's edit is
9
+ // overwritten in silence, and its screen changes when the other edit syncs.
10
+
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+
13
+ import { SyncEngine, type Row } from "./index";
14
+ import type { Storage } from "./storage";
15
+ import { createTestEnv, type TestEnv } from "./test-harness";
16
+
17
+ class MemoryStorage implements Storage {
18
+ private map = new Map<string, string>();
19
+ get(key: string) {
20
+ return this.map.get(key) ?? null;
21
+ }
22
+ set(key: string, value: string) {
23
+ this.map.set(key, value);
24
+ }
25
+ remove(key: string) {
26
+ this.map.delete(key);
27
+ }
28
+ }
29
+
30
+ describe("two clients edit one row offline", () => {
31
+ let env: TestEnv | null = null;
32
+ let second: SyncEngine | null = null;
33
+ afterEach(async () => {
34
+ second?.stop();
35
+ second = null;
36
+ if (env) await env.dispose();
37
+ env = null;
38
+ });
39
+
40
+ async function twoClients() {
41
+ // Client A is the harness engine; client B is a second engine against
42
+ // the same server, signed in as another rep.
43
+ env = createTestEnv({ transport: "poll", appName: "rep-a" });
44
+ env.signIn({ userId: "rep-a" });
45
+ env.server.seed("Lead", [{ id: "l1", status: "new", note: "", phone: "" }]);
46
+ await env.start();
47
+ const storage = new MemoryStorage();
48
+ storage.set("pylon:rep-b:token", env.server.signIn({ userId: "rep-b" }));
49
+ second = new SyncEngine({
50
+ baseUrl: "http://test.invalid",
51
+ appName: "rep-b",
52
+ persist: false,
53
+ storage,
54
+ transport: "poll",
55
+ multiTab: false,
56
+ });
57
+ await second.start();
58
+ await env.flush();
59
+ return { a: env.engine, b: second, server: env.server };
60
+ }
61
+
62
+ /** Let each push's catch-up pull finish, then pull the other's edit
63
+ * (on the poll transport nothing pushes it). */
64
+ async function syncBoth(a: SyncEngine, b: SyncEngine) {
65
+ await env!.flush(20);
66
+ await a.pull();
67
+ await b.pull();
68
+ }
69
+
70
+ async function offlineUpdate(engine: SyncEngine, patch: Row) {
71
+ env!.server.primeNextPushOutcome({ kind: "network" });
72
+ await engine.update("Lead", "l1", patch);
73
+ expect(engine.pendingCount()).toBe(1);
74
+ }
75
+
76
+ test("different fields: both edits survive", async () => {
77
+ const { a, b, server } = await twoClients();
78
+ await offlineUpdate(a, { status: "sold" });
79
+ await offlineUpdate(b, { note: "dog in yard" });
80
+
81
+ await a.push();
82
+ await b.push();
83
+ await syncBoth(a, b);
84
+
85
+ const [row] = await server.listEntityRows("Lead", env!.token);
86
+ expect(row).toMatchObject({ status: "sold", note: "dog in yard" });
87
+ expect(a.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
88
+ expect(b.store.get("Lead", "l1")).toMatchObject({ status: "sold", note: "dog in yard" });
89
+ });
90
+
91
+ test("same field: the last push wins and neither client is told", async () => {
92
+ const { a, b, server } = await twoClients();
93
+ await offlineUpdate(a, { status: "sold" });
94
+ await offlineUpdate(b, { status: "not interested" });
95
+
96
+ // A reconnects first, then B.
97
+ await a.push();
98
+ await b.push();
99
+ await syncBoth(a, b);
100
+
101
+ const [row] = await server.listEntityRows("Lead", env!.token);
102
+ expect(row).toMatchObject({ status: "not interested" });
103
+ // A's edit was replaced: A now shows B's value...
104
+ expect(a.store.get("Lead", "l1")).toMatchObject({ status: "not interested" });
105
+ // ...and nothing reports it. Both writes were applied, so neither
106
+ // outbox has a failed write.
107
+ expect(a.outbox()).toEqual({ pending: [], failed: [] });
108
+ expect(b.outbox()).toEqual({ pending: [], failed: [] });
109
+ });
110
+ });
@@ -0,0 +1,333 @@
1
+ // The outbox: list pending and failed writes, retry a failed write (with
2
+ // corrected fields), dismiss one, and notify subscribers on every change.
3
+ //
4
+ // Before: a write the server rejected was rolled back and kept in the queue
5
+ // as `failed`, but nothing listed it, so the user never learned it was lost.
6
+
7
+ import "fake-indexeddb/auto";
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+
10
+ import {
11
+ IndexedDBPersistence,
12
+ MutationRejectedError,
13
+ OUTBOX_CHANNEL,
14
+ SyncEngine,
15
+ } from "./index";
16
+ import {
17
+ MutationQueue,
18
+ type MutationQueuePersistence,
19
+ type PendingMutation,
20
+ } from "./mutation-queue";
21
+ import { createTestEnv, type TestEnv } from "./test-harness";
22
+
23
+ /** In-memory stand-in for the IndexedDB queue store. */
24
+ class MemoryQueueStore implements MutationQueuePersistence {
25
+ rows = new Map<string, PendingMutation>();
26
+ async saveAll(mutations: PendingMutation[]) {
27
+ this.rows = new Map(mutations.map((m) => [m.id, structuredClone(m)]));
28
+ }
29
+ async loadAll() {
30
+ return Array.from(this.rows.values(), (m) => structuredClone(m));
31
+ }
32
+ async saveChanges(upserts: PendingMutation[], removedIds: string[]) {
33
+ for (const m of upserts) this.rows.set(m.id, structuredClone(m));
34
+ for (const id of removedIds) this.rows.delete(id);
35
+ }
36
+ }
37
+
38
+ const tick = () => new Promise((r) => setTimeout(r, 0));
39
+
40
+ describe("MutationQueue", () => {
41
+ test("a failed write survives clear() and a reload", async () => {
42
+ const disk = new MemoryQueueStore();
43
+ const q = new MutationQueue(disk);
44
+ const applied = q.add({ entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1" } }, undefined, "u1");
45
+ const failed = q.add({ entity: "Knock", row_id: "k2", kind: "insert", data: { id: "k2" } }, undefined, "u1");
46
+ q.markApplied(applied);
47
+ q.markFailed(failed, "door number is required", "VALIDATION_FAILED");
48
+ q.clear();
49
+ await tick();
50
+ expect(q.failed().map((m) => m.id)).toEqual([failed]);
51
+
52
+ const reloaded = new MutationQueue(disk);
53
+ await reloaded.hydrate();
54
+ const [m] = reloaded.failed();
55
+ expect(m).toMatchObject({
56
+ id: failed,
57
+ status: "failed",
58
+ error: "door number is required",
59
+ errorCode: "VALIDATION_FAILED",
60
+ owner: "u1",
61
+ });
62
+ expect(typeof m!.createdAt).toBe("number");
63
+ expect(reloaded.get(applied)).toBeUndefined();
64
+ });
65
+
66
+ test("retry re-queues as pending under a new op id, with the owner and merged fields", async () => {
67
+ const disk = new MemoryQueueStore();
68
+ const q = new MutationQueue(disk);
69
+ const id = q.add(
70
+ { entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1", door: "", outcome: "no answer" } },
71
+ undefined,
72
+ "u1",
73
+ );
74
+ const createdAt = q.get(id)!.createdAt;
75
+ q.markFailed(id, "door number is required", "VALIDATION_FAILED");
76
+ const newId = q.retry(id, { door: "14B", id: "attempt-to-change-id" }, undefined);
77
+ await tick();
78
+
79
+ expect(newId).not.toBe(id);
80
+ expect(q.get(id)).toBeUndefined();
81
+ const m = q.get(newId)!;
82
+ expect(m.status).toBe("pending");
83
+ expect(m.owner).toBe("u1");
84
+ expect(m.createdAt).toBe(createdAt);
85
+ expect(m.change.op_id).toBe(newId);
86
+ expect(m.change.data).toEqual({ id: "k1", door: "14B", outcome: "no answer" });
87
+ // One persistence write removed the old entry and added the new one.
88
+ expect(Array.from(disk.rows.keys())).toEqual([newId]);
89
+ expect(() => q.retry(newId, undefined, undefined)).toThrow();
90
+ });
91
+
92
+ test("dismiss removes a failed write from memory and persistence; a pending one stays", async () => {
93
+ const disk = new MemoryQueueStore();
94
+ const q = new MutationQueue(disk);
95
+ const failed = q.add({ entity: "Knock", row_id: "k1", kind: "insert", data: { id: "k1" } });
96
+ const pending = q.add({ entity: "Knock", row_id: "k2", kind: "insert", data: { id: "k2" } });
97
+ q.markFailed(failed, "no");
98
+ expect(q.dismiss(pending)).toBe(false);
99
+ expect(q.dismiss(failed)).toBe(true);
100
+ await tick();
101
+ expect(q.get(failed)).toBeUndefined();
102
+ expect(Array.from(disk.rows.keys())).toEqual([pending]);
103
+ });
104
+
105
+ test("writes from an older client (no createdAt) sort first", () => {
106
+ const q = new MutationQueue();
107
+ const newer = q.add({ entity: "K", row_id: "a", kind: "insert", data: { id: "a" } });
108
+ const older = q.add({ entity: "K", row_id: "b", kind: "insert", data: { id: "b" } });
109
+ delete q.get(older)!.createdAt;
110
+ q.markFailed(newer, "x");
111
+ q.markFailed(older, "y");
112
+ expect(q.failed().map((m) => m.id)).toEqual([older, newer]);
113
+ });
114
+ });
115
+
116
+ describe("SyncEngine outbox", () => {
117
+ let env: TestEnv | null = null;
118
+ afterEach(async () => {
119
+ if (env) await env.dispose();
120
+ env = null;
121
+ });
122
+
123
+ test("lists pending and failed writes with their fields and errors", async () => {
124
+ env = createTestEnv({ transport: "poll" });
125
+ env.signIn({ userId: "u1" });
126
+ await env.start();
127
+ env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
128
+ await expect(env.engine.insert("Knock", { door: "", outcome: "no answer" })).rejects.toThrow();
129
+ env.server.primeNextPushOutcome({ kind: "network" });
130
+ await env.engine.insert("Knock", { door: "12", outcome: "sold" });
131
+
132
+ const { pending, failed } = env.engine.outbox();
133
+ expect(pending).toHaveLength(1);
134
+ expect(pending[0]).toMatchObject({ entity: "Knock", kind: "insert", data: { door: "12", outcome: "sold" } });
135
+ expect(pending[0]!.error).toBeUndefined();
136
+ expect(failed).toHaveLength(1);
137
+ expect(failed[0]).toMatchObject({
138
+ entity: "Knock",
139
+ kind: "insert",
140
+ data: { door: "", outcome: "no answer" },
141
+ error: "door number is required",
142
+ errorCode: "VALIDATION_FAILED",
143
+ });
144
+ // Same object until the queue changes.
145
+ expect(env.engine.outbox()).toBe(env.engine.outbox());
146
+ });
147
+
148
+ test("subscribers hear add, applied, failed, retry and dismiss", async () => {
149
+ env = createTestEnv({ transport: "poll" });
150
+ env.signIn({ userId: "u1" });
151
+ await env.start();
152
+ let calls = 0;
153
+ env.engine.store.subscribe(() => {
154
+ calls += 1;
155
+ }, OUTBOX_CHANNEL);
156
+ const heard = async (fn: () => Promise<unknown> | unknown) => {
157
+ const before = calls;
158
+ try {
159
+ await fn();
160
+ } catch {
161
+ // A rejected write still changes the outbox.
162
+ }
163
+ await env!.flush(5);
164
+ return calls > before;
165
+ };
166
+
167
+ // add + applied
168
+ expect(await heard(() => env!.engine.insert("Knock", { door: "1" }))).toBe(true);
169
+ // add + failed
170
+ env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "required");
171
+ expect(await heard(() => env!.engine.insert("Knock", { door: "" }))).toBe(true);
172
+ const failedId = env.engine.outbox().failed[0]!.id;
173
+ // retry (fails again)
174
+ expect(await heard(() => env!.engine.retryWrite(failedId))).toBe(true);
175
+ // dismiss
176
+ const again = env.engine.outbox().failed[0]!.id;
177
+ expect(await heard(() => env!.engine.dismissWrite(again))).toBe(true);
178
+ expect(env.engine.outbox().failed).toHaveLength(0);
179
+
180
+ // Entity listeners are not woken by outbox-only changes.
181
+ let entityCalls = 0;
182
+ env.engine.store.subscribe(() => {
183
+ entityCalls += 1;
184
+ }, "Other");
185
+ env.engine.mutations.markFailed("nope", "x");
186
+ env.engine.store.notify(OUTBOX_CHANNEL);
187
+ expect(entityCalls).toBe(0);
188
+ });
189
+
190
+ test("retry with corrected data is applied; the rejected op is never applied", async () => {
191
+ env = createTestEnv({ transport: "poll" });
192
+ env.signIn({ userId: "u1" });
193
+ await env.start();
194
+ env.server.rejectWritesWhere("Knock", (d) => d.door === "", "VALIDATION_FAILED", "door number is required");
195
+
196
+ let rowId = "";
197
+ try {
198
+ await env.engine.insert("Knock", { door: "", outcome: "sold" });
199
+ } catch (err) {
200
+ rowId = (err as MutationRejectedError).rowId;
201
+ }
202
+ // The rejection rolled the row back.
203
+ expect(env.engine.store.get("Knock", rowId)).toBeNull();
204
+ const failed = env.engine.outbox().failed[0]!;
205
+
206
+ const newOp = await env.engine.retryWrite(failed.id, { door: "14B" });
207
+ expect(newOp).not.toBe(failed.id);
208
+ expect(env.engine.store.get("Knock", rowId)).toMatchObject({ door: "14B", outcome: "sold" });
209
+ expect(env.engine.outbox()).toEqual({ pending: [], failed: [] });
210
+ const onServer = await env.server.listEntityRows("Knock", env.token);
211
+ expect(onServer).toEqual([{ id: rowId, door: "14B", outcome: "sold" }]);
212
+ });
213
+
214
+ test("a retry the server rejects again is failed again with the new error", async () => {
215
+ env = createTestEnv({ transport: "poll" });
216
+ env.signIn({ userId: "u1" });
217
+ await env.start();
218
+ env.server.seed("Knock", [{ id: "k1", door: "3", outcome: "no answer" }]);
219
+ await env.engine.pull();
220
+ env.server.rejectWritesWhere("Knock", (d) => d.outcome === "bogus", "VALIDATION_FAILED", "unknown outcome");
221
+ env.server.rejectWritesWhere("Knock", (d) => d.outcome === "worse", "POLICY_DENIED", "not allowed");
222
+ await expect(env.engine.update("Knock", "k1", { outcome: "bogus" })).rejects.toThrow();
223
+ expect(env.engine.store.get("Knock", "k1")).toMatchObject({ outcome: "no answer" });
224
+
225
+ const id = env.engine.outbox().failed[0]!.id;
226
+ await expect(env.engine.retryWrite(id, { outcome: "worse" })).rejects.toMatchObject({
227
+ code: "POLICY_DENIED",
228
+ });
229
+ const [failed] = env.engine.outbox().failed;
230
+ expect(failed).toMatchObject({ errorCode: "POLICY_DENIED", error: "not allowed", data: { outcome: "worse" } });
231
+ expect(failed!.id).not.toBe(id);
232
+ // Rolled back to the server's value again.
233
+ expect(env.engine.store.get("Knock", "k1")).toMatchObject({ outcome: "no answer" });
234
+ });
235
+
236
+ test("a failed write is never retried as another user", async () => {
237
+ env = createTestEnv({ transport: "poll" });
238
+ env.signIn({ userId: "u1" });
239
+ await env.start();
240
+ env.server.denyWrites("Knock", "POLICY_DENIED", "no");
241
+ await expect(env.engine.insert("Knock", { door: "1" })).rejects.toThrow();
242
+ const id = env.engine.outbox().failed[0]!.id;
243
+
244
+ env.signIn({ userId: "u2" });
245
+ await env.engine.refreshResolvedSession();
246
+ await env.flush(10);
247
+ await expect(env.engine.retryWrite(id)).rejects.toMatchObject({ code: "OWNER_MISMATCH" });
248
+ expect(env.server.receivedPushKeys.filter((k) => k.startsWith("Knock/"))).toHaveLength(1);
249
+ });
250
+
251
+ test("retryWrite and dismissWrite refuse a write that is not failed", async () => {
252
+ env = createTestEnv({ transport: "poll" });
253
+ env.signIn({ userId: "u1" });
254
+ await env.start();
255
+ env.server.primeNextPushOutcome({ kind: "network" });
256
+ await env.engine.insert("Knock", { door: "1" });
257
+ const id = env.engine.outbox().pending[0]!.id;
258
+ await expect(env.engine.retryWrite(id)).rejects.toThrow("no failed write");
259
+ expect(env.engine.dismissWrite(id)).toBe(false);
260
+ });
261
+ });
262
+
263
+ describe("pending writes after a reload", () => {
264
+ const realFetch = globalThis.fetch;
265
+ afterEach(() => {
266
+ globalThis.fetch = realFetch;
267
+ });
268
+
269
+ test("rows written offline are on screen again before the network answers", async () => {
270
+ // Offline: every request fails.
271
+ globalThis.fetch = (async () => {
272
+ throw new TypeError("Failed to fetch");
273
+ }) as unknown as typeof fetch;
274
+ const name = `reload-${Math.random().toString(36).slice(2)}`;
275
+ const disk = new IndexedDBPersistence(name);
276
+ await disk.open();
277
+ await disk.saveIdentity("u1");
278
+ await disk.saveCursor({ last_seq: 5 });
279
+ await disk.saveRow("Knock", "k-old", { id: "k-old", door: "1", outcome: "no answer" });
280
+ const { IndexedDBMutationPersistence } = await import("./persistence");
281
+ const now = Date.now();
282
+ await new IndexedDBMutationPersistence(disk).saveAll([
283
+ {
284
+ id: "op-insert",
285
+ change: { entity: "Knock", row_id: "k-new", kind: "insert", data: { id: "k-new", door: "2", outcome: "sold" }, op_id: "op-insert" },
286
+ status: "pending",
287
+ owner: "u1",
288
+ createdAt: now - 2000,
289
+ },
290
+ {
291
+ id: "op-update",
292
+ change: { entity: "Knock", row_id: "k-old", kind: "update", data: { outcome: "callback" }, op_id: "op-update" },
293
+ status: "pending",
294
+ owner: "u1",
295
+ prevRow: { id: "k-old", door: "1", outcome: "no answer" },
296
+ createdAt: now - 1000,
297
+ },
298
+ {
299
+ id: "op-failed",
300
+ change: { entity: "Knock", row_id: "k-bad", kind: "insert", data: { id: "k-bad" }, op_id: "op-failed" },
301
+ status: "failed",
302
+ error: "rejected",
303
+ owner: "u1",
304
+ createdAt: now - 500,
305
+ },
306
+ ]);
307
+
308
+ disk.connection?.close();
309
+
310
+ const reopened = new IndexedDBPersistence(name);
311
+ const engine = new SyncEngine({
312
+ baseUrl: "http://test.invalid",
313
+ appName: name,
314
+ persistence: reopened,
315
+ multiTab: false,
316
+ transport: "poll",
317
+ pollInterval: 1e9,
318
+ });
319
+ const started = engine.start().catch(() => {});
320
+ for (let i = 0; i < 50 && !engine.isHydrated(); i++) await new Promise((r) => setTimeout(r, 10));
321
+ await new Promise((r) => setTimeout(r, 20));
322
+ expect(engine.store.get("Knock", "k-new")).toMatchObject({ door: "2", outcome: "sold" });
323
+ expect(engine.store.get("Knock", "k-old")).toMatchObject({ outcome: "callback" });
324
+ // A failed write was rolled back; it is not re-applied.
325
+ expect(engine.store.get("Knock", "k-bad")).toBeNull();
326
+ expect(engine.outbox().pending.map((m) => m.id)).toEqual(["op-insert", "op-update"]);
327
+ expect(engine.outbox().failed.map((m) => m.id)).toEqual(["op-failed"]);
328
+ engine.stop();
329
+ await started;
330
+ // An open connection blocks other suites' deleteDatabase.
331
+ reopened.connection?.close();
332
+ });
333
+ });
@@ -221,6 +221,9 @@ export class TestServer {
221
221
  * answers. Lets a test queue a write while a push is in flight. */
222
222
  pushGate: Promise<void> | null = null;
223
223
 
224
+ /** The file endpoints (`/api/files/init`, PUT, `/api/files/confirm`). */
225
+ readonly files = new TestFileServer(this);
226
+
224
227
  /** Entities whose pushed writes are rejected per op, like a write
225
228
  * policy denial on the real server (HTTP 200 with a per-op error). */
226
229
  private deniedWrites = new Map<string, { code: string; message: string }>();
@@ -228,6 +231,22 @@ export class TestServer {
228
231
  this.deniedWrites.set(entity, { code, message });
229
232
  }
230
233
 
234
+ /** Reject a pushed insert/update of `entity` whose fields match
235
+ * `predicate`, like a write policy that checks a field value. */
236
+ private conditionalDenials: Array<{
237
+ entity: string;
238
+ predicate: (data: Row) => boolean;
239
+ error: { code: string; message: string };
240
+ }> = [];
241
+ rejectWritesWhere(
242
+ entity: string,
243
+ predicate: (data: Row) => boolean,
244
+ code = "POLICY_DENIED",
245
+ message = "write denied by policy",
246
+ ): void {
247
+ this.conditionalDenials.push({ entity, predicate, error: { code, message } });
248
+ }
249
+
231
250
  private nextPushOutcome:
232
251
  | { kind: "network" }
233
252
  | { kind: "status"; status: number }
@@ -390,6 +409,11 @@ export class TestServer {
390
409
  if (denied) {
391
410
  return { op_id, status: "error", error: denied };
392
411
  }
412
+ if (kind !== "delete") {
413
+ const merged = { ...(this.rows.get(entity)?.get(row_id) ?? {}), ...(change.data ?? {}) } as Row;
414
+ const rule = this.conditionalDenials.find((d) => d.entity === entity && d.predicate(merged));
415
+ if (rule) return { op_id, status: "error", error: rule.error };
416
+ }
393
417
  const exists = this.rows.get(entity)?.has(row_id) ?? false;
394
418
  if (kind === "insert") {
395
419
  this.insert(entity, { ...(change.data ?? {}), id: row_id } as Row);
@@ -569,3 +593,70 @@ export class TestServer {
569
593
  }
570
594
  }
571
595
  }
596
+
597
+ /**
598
+ * In-memory stand-in for the runtime's file routes. Records every request
599
+ * so a test can count uploads, and lets a test refuse a file (`rejectInit`)
600
+ * or drop the connection during the next PUT (`dropNextPut`).
601
+ */
602
+ export class TestFileServer {
603
+ private nextId = 1;
604
+ private slots = new Map<string, { owner: string | null; filename: string; size: number; bytes?: number }>();
605
+ readonly stored = new Map<string, { filename: string; size: number; owner: string | null }>();
606
+ initCount = 0;
607
+ putCount = 0;
608
+ private rejection: { status: number; code: string; message: string } | null = null;
609
+ private dropPut = false;
610
+
611
+ constructor(private readonly server: TestServer) {}
612
+
613
+ /** Refuse every init with this status, like a type or size check. */
614
+ rejectInit(status: number, code: string, message: string): void {
615
+ this.rejection = { status, code, message };
616
+ }
617
+ clearRejection(): void {
618
+ this.rejection = null;
619
+ }
620
+ /** When set, every PUT waits for it before it answers. */
621
+ putGate: Promise<void> | null = null;
622
+ /** The next PUT fails like a dropped connection (no status). */
623
+ dropNextPut(): void {
624
+ this.dropPut = true;
625
+ }
626
+
627
+ init(token: string | undefined, body: { filename?: string; size?: number }) {
628
+ this.initCount += 1;
629
+ if (this.rejection) {
630
+ const r = this.rejection;
631
+ return { status: r.status, body: { error: { code: r.code, message: r.message } } };
632
+ }
633
+ const id = `f_${this.nextId++}`;
634
+ const owner = this.server.authContextFor(token).userId;
635
+ this.slots.set(id, { owner, filename: body.filename ?? "upload", size: body.size ?? 0 });
636
+ return { status: 200, body: { uploadUrl: `/api/files/local-put/${id}`, assetId: id } };
637
+ }
638
+
639
+ put(id: string, bytes: unknown) {
640
+ this.putCount += 1;
641
+ if (this.dropPut) {
642
+ this.dropPut = false;
643
+ throw new TypeError("simulated dropped connection during upload");
644
+ }
645
+ const slot = this.slots.get(id);
646
+ if (!slot) return { status: 404, body: { error: { code: "NOT_FOUND" } } };
647
+ slot.bytes = bytes instanceof ArrayBuffer ? bytes.byteLength : slot.size;
648
+ return { status: 200, body: null };
649
+ }
650
+
651
+ confirm(token: string | undefined, id: string) {
652
+ const slot = this.slots.get(id);
653
+ if (!slot || slot.bytes === undefined) {
654
+ return { status: 400, body: { error: { code: "NOT_UPLOADED" } } };
655
+ }
656
+ if (slot.owner !== this.server.authContextFor(token).userId) {
657
+ return { status: 403, body: { error: { code: "FORBIDDEN" } } };
658
+ }
659
+ this.stored.set(id, { filename: slot.filename, size: slot.bytes, owner: slot.owner });
660
+ return { status: 200, body: { id, url: `/api/files/${id}`, size: slot.bytes } };
661
+ }
662
+ }
@@ -374,6 +374,23 @@ async function handle(
374
374
  };
375
375
  }
376
376
 
377
+ // File uploads: /api/files/init, the PUT to the returned URL, and
378
+ // /api/files/confirm (see TestServer.files).
379
+ if (url.endsWith("/api/files/init") && method === "POST") {
380
+ const body = typeof _init?.body === "string" ? JSON.parse(_init.body) : {};
381
+ return server.files.init(token, body);
382
+ }
383
+ const put = url.match(/\/api\/files\/local-put\/([^/?]+)$/);
384
+ if (put && method === "PUT") {
385
+ const bytes = _init?.body instanceof Blob ? await _init.body.arrayBuffer() : _init?.body;
386
+ if (server.files.putGate) await server.files.putGate;
387
+ return server.files.put(put[1]!, bytes);
388
+ }
389
+ if (url.endsWith("/api/files/confirm") && method === "POST") {
390
+ const body = typeof _init?.body === "string" ? JSON.parse(_init.body) : {};
391
+ return server.files.confirm(token, body.assetId);
392
+ }
393
+
377
394
  // Anything else: 404 with a clear error so test failures point
378
395
  // at the exact missing route rather than a generic JSON parse.
379
396
  return {