cursedbelt-server 4.8.0 → 4.11.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.
@@ -1,7 +1,7 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { beforeEach, describe, expect, test } from 'bun:test';
3
3
  import { createFakeD1Binding } from './fakeD1';
4
- import { perInvocation } from './invocation';
4
+ import { createPendingWrites, perInvocation } from './invocation';
5
5
  import { LIMITS } from './limits';
6
6
  import { createLocalD1 } from './local';
7
7
  import { createRemoteD1 } from './remote';
@@ -172,3 +172,67 @@ describe('the budget counts the REMOTE driver identically', () => {
172
172
  expect(db.used).toBe(2);
173
173
  });
174
174
  });
175
+
176
+ describe('the pending writes of one invocation', () => {
177
+ test('settle() waits for every collected write before it resolves', async () => {
178
+ const landed: number[] = [];
179
+ const writes = createPendingWrites();
180
+ const slow = (n: number, ms: number) =>
181
+ new Promise<void>((resolve) =>
182
+ setTimeout(() => {
183
+ landed.push(n);
184
+ resolve();
185
+ }, ms),
186
+ );
187
+
188
+ writes.add(slow(1, 20));
189
+ writes.add(slow(2, 5));
190
+ // Nothing has landed yet: this is the state a response sent without settling escapes in.
191
+ expect(landed).toEqual([]);
192
+
193
+ await writes.settle();
194
+ expect(landed.sort()).toEqual([1, 2]);
195
+ });
196
+
197
+ test('🔴 a failed write REJECTS settle() — `all`, not `allSettled`', async () => {
198
+ // The failure path this primitive exists for. `allSettled` would swallow the rejection
199
+ // and the entry point would answer 200 to a write that never landed, which reads to the
200
+ // person as the sign-out having taken.
201
+ const writes = createPendingWrites();
202
+ writes.add(Promise.resolve('this one is fine'));
203
+ writes.add(Promise.reject(new Error('D1_ERROR: no such table: sessions')));
204
+ await expect(writes.settle()).rejects.toThrow('no such table: sessions');
205
+ });
206
+
207
+ test('it collects the real statements a store queues, and they are durable after settle()', async () => {
208
+ // The whole shape in miniature: a synchronous caller queues D1 work it cannot await,
209
+ // and the row exists only because somebody settled.
210
+ const sqlite = seeded();
211
+ const db = perInvocation(createLocalD1(sqlite));
212
+ const writes = createPendingWrites();
213
+
214
+ const revoke = (id: number, name: string): void => {
215
+ writes.add(db.prepare('INSERT INTO people (id, name) VALUES (?, ?)').bind(id, name).run());
216
+ };
217
+ revoke(90, 'p90');
218
+ revoke(91, 'p91');
219
+
220
+ await writes.settle();
221
+ const rows = await db.prepare('SELECT name FROM people WHERE id >= 90 ORDER BY id').all<{ name: string }>();
222
+ expect(rows.results.map((r) => r.name)).toEqual(['p90', 'p91']);
223
+ // Two writes plus the read back — the collector adds no queries of its own.
224
+ expect(db.used).toBe(3);
225
+ });
226
+
227
+ test('an invocation that wrote nothing settles clean', async () => {
228
+ await expect(createPendingWrites().settle()).resolves.toBeUndefined();
229
+ });
230
+
231
+ test('it satisfies the two-method collector a store is handed', async () => {
232
+ // `cursedauth/d1-stores` declares this shape under its own name and may not import it
233
+ // from here. Structural, in both directions — this is that assertion at compile time.
234
+ const collector: { add(promise: Promise<unknown>): void } = createPendingWrites();
235
+ collector.add(Promise.resolve());
236
+ expect(typeof collector.add).toBe('function');
237
+ });
238
+ });
@@ -39,6 +39,14 @@
39
39
  * It wraps either driver, because it sits ABOVE the seam and only counts. That is what
40
40
  * makes the local gate able to prove a production-only limit: wrap a local database in a
41
41
  * test, run the loop, and the laptop fails exactly where the Worker would.
42
+ *
43
+ * ## The other thing that is true for the span of one request: {@link createPendingWrites}
44
+ *
45
+ * A Worker port's synchronous stores answer from a snapshot and queue their durable writes
46
+ * as promises, which the entry point awaits BEFORE the response leaves. That collector has
47
+ * the same lifetime as the budget above — one per invocation, constructed by the caller in
48
+ * the same breath — so it lives in the same file. See its own header for why it was moved
49
+ * here on 2026-09-19.
42
50
  */
43
51
 
44
52
  import { LIMITS } from './limits';
@@ -205,3 +213,81 @@ export function perInvocation(db: D1LikeDatabase, opts: { max?: number } = {}):
205
213
  },
206
214
  };
207
215
  }
216
+
217
+ /**
218
+ * Where a durable write goes on its way out of one invocation.
219
+ *
220
+ * Deliberately two members, and the narrower `add`-only half is what a STORE takes. A store
221
+ * that also held `settle` could end the request's writes from inside the request, which is
222
+ * the entry point's job and nobody else's. `cursedauth/d1-stores` declares exactly this
223
+ * two-method shape under its own name (`WriteCollector`) because that package sits UNDER
224
+ * this one and may not import it — structural typing means an object made here satisfies it
225
+ * verbatim, with no dependency edge in either direction.
226
+ */
227
+ export interface WriteCollector {
228
+ add(promise: Promise<unknown>): void;
229
+ }
230
+
231
+ /** A write that must land before the response is returned. See {@link createPendingWrites}. */
232
+ export interface PendingWrites extends WriteCollector {
233
+ /** Await every collected write. Called BEFORE the response is returned, never after. */
234
+ settle(): Promise<void>;
235
+ }
236
+
237
+ /**
238
+ * Collect the durable writes of ONE invocation, to be awaited before the response leaves.
239
+ *
240
+ * ## Why this is the Worker-write primitive and not a convenience
241
+ *
242
+ * A ported app's synchronous stores cannot await — their signatures are somebody else's
243
+ * published contract — so the shape every port lands on is: one read fills a snapshot, every
244
+ * sync call answers from it, and every mutation both updates the snapshot and pushes its
245
+ * statement's promise in here. The entry point then does the awaiting:
246
+ *
247
+ * ```ts
248
+ * export default {
249
+ * async fetch(req: Request, env: Env) {
250
+ * const db = perInvocation(createRemoteD1(env.DB));
251
+ * const writes = createPendingWrites();
252
+ * const res = await app.fetch(req, { db, writes });
253
+ * await writes.settle(); // 🔴 BEFORE the response, never after
254
+ * return res;
255
+ * },
256
+ * };
257
+ * ```
258
+ *
259
+ * 🔴 **`settle()` before the response, never `ctx.waitUntil`.** A Worker isolate is per-colo
260
+ * and short-lived; a write acknowledged by a 200 and then dropped means "sign out" reported
261
+ * success and left the credential live — a failure invisible to any single-process test and
262
+ * indistinguishable, to the person, from the feature being broken.
263
+ *
264
+ * 🔴 **`Promise.all`, not `allSettled`.** A failed durable write must not be reported as a
265
+ * success. It surfaces as a 500 from the entry point, which is the honest answer to "did my
266
+ * sign-out take". `allSettled` here would convert every lost write into a silent 200 — the
267
+ * same outcome as not collecting them at all, reached by a road that looks careful.
268
+ *
269
+ * ## Why it lives here
270
+ *
271
+ * It was written three times before it was published once — `apps/patterns/worker/stores.ts`,
272
+ * `apps/collections/worker/stores.ts` and `libs/cursedauth/src/d1Stores.ts`, byte-identical
273
+ * but for a comment, with six more ports queued behind them. It is a D1-INVOCATION primitive
274
+ * rather than an auth one, so it belongs beside {@link perInvocation}: both are constructed
275
+ * per request, both describe what is true for the span of one, and neither knows anything
276
+ * about who is signed in.
277
+ *
278
+ * Ordering is NOT what this gives you. Everything added is already in flight, so two writes
279
+ * that must land in order go in one `batch()`, never in two `add`s — a DELETE that must
280
+ * precede its INSERT will otherwise race and trip a unique index.
281
+ */
282
+ export function createPendingWrites(): PendingWrites {
283
+ const pending: Promise<unknown>[] = [];
284
+ return {
285
+ add: (promise) => void pending.push(promise),
286
+ async settle() {
287
+ // `all`, not `allSettled`: a failed durable write must not be reported as a
288
+ // success. It surfaces as a 500 from the entry point, which is the honest answer
289
+ // to "did my sign-out take".
290
+ await Promise.all(pending);
291
+ },
292
+ };
293
+ }
@@ -39,7 +39,7 @@ describe('chunked upload', () => {
39
39
  now: () => ++clock,
40
40
  });
41
41
  const ledger = createStorageOpsLedger(db, () => ++clock);
42
- const minted: Array<{ key: string; sid: string; ci: number; tc: number }> = [];
42
+ const minted: Array<{ key: string; sid: string; ci: number; tc: number; sz?: number }> = [];
43
43
  const svc = createChunkedUploadService({
44
44
  catalogue,
45
45
  ledger,
@@ -67,6 +67,10 @@ describe('chunked upload', () => {
67
67
  expect(init.chunkUploadUrls).toHaveLength(3);
68
68
  expect(minted.map((m) => m.ci)).toEqual([0, 1, 2]);
69
69
  expect(minted.every((m) => m.tc === 3 && m.sid === init.sid)).toBe(true);
70
+ // 🔴 The SOURCE length, on every chunk. `tc` is derived from it and a count is not a length:
71
+ // without this the minter has nothing to put in the token's `sz` claim, and binary-server's
72
+ // only end-to-end check of the finished object against its source stays inert.
73
+ expect(minted.map((m) => m.sz)).toEqual([20 * 1024 * 1024, 20 * 1024 * 1024, 20 * 1024 * 1024]);
70
74
  // The mint targets exactly the key the catalogue will look for on complete.
71
75
  expect(init.key).toBe(catalogueUploadKey('u1', init.fileId, 'image/png'));
72
76
 
@@ -78,6 +82,16 @@ describe('chunked upload', () => {
78
82
  expect(svc.pending()).toBe(1);
79
83
  });
80
84
 
85
+ test('🔴 a size the caller could not state is OMITTED, never declared as zero', async () => {
86
+ // `sz: 0` would compare zero to the assembled length and refuse every assemble — so an
87
+ // unknown size degrades to exactly today's behaviour (no claim, recorded undeclared) rather
88
+ // than to a 422. `chunkCount` still clamps to one chunk, which is the old `req.size || 1`.
89
+ const { svc, minted } = build();
90
+ const init = await svc.init(CTX, { name: 'unknown.bin', mime: 'application/octet-stream', size: 0 });
91
+ expect(init.chunkCount).toBe(1);
92
+ expect(minted.map((m) => m.sz)).toEqual([undefined]);
93
+ });
94
+
81
95
  test('complete registers the catalogue row over the assembled bytes + marks the op done', async () => {
82
96
  const { catalogue, ledger, svc } = build();
83
97
  const init = await svc.init(CTX, {
@@ -64,7 +64,7 @@ export interface ChunkedUploadService {
64
64
  /**
65
65
  * Mint the direct-to-backend PUT URL for one chunk. The app supplies this so the module stays
66
66
  * backend-agnostic. For binary-server: `${base}/upload-chunk/${app}/${key}?token=` with a token
67
- * carrying { k, uid, sid, ci, tc }.
67
+ * carrying { k, uid, sid, ci, tc, sz }.
68
68
  */
69
69
  export type MintChunkUpload = (args: {
70
70
  key: string;
@@ -72,6 +72,17 @@ export type MintChunkUpload = (args: {
72
72
  ci: number;
73
73
  tc: number;
74
74
  owner: string;
75
+ /**
76
+ * The SOURCE byte length this session was opened with — pass it straight into the token's `sz`
77
+ * claim (`FileTokenClaims.sz`), which is the only end-to-end integrity check the upload path
78
+ * has: binary-server compares it to the assembled object and answers 422 instead of indexing a
79
+ * short one.
80
+ *
81
+ * 🔴 `undefined` when the caller could not state a positive length, and then the claim must be
82
+ * OMITTED rather than sent as `0` — a declared zero refuses every assemble. That is the same
83
+ * posture the claim itself has: optional, so that declaring it could never take an upload down.
84
+ */
85
+ sz?: number;
75
86
  }) => Promise<string>;
76
87
 
77
88
  export function createChunkedUploadService(cfg: {
@@ -110,9 +121,14 @@ export function createChunkedUploadService(cfg: {
110
121
  tags: req.tags,
111
122
  createdAt: now(),
112
123
  });
124
+ // 🔴 The source length rides along, because `chunkCount` above is derived from it and a
125
+ // count is not a length: a session that splits the file wrongly stores whatever it sent,
126
+ // faithfully, with a 201. Omitted rather than zeroed when the caller stated no positive
127
+ // size — `sz: 0` would refuse every assemble. See `MintChunkUpload.sz`.
128
+ const declaredSize = Number.isFinite(req.size) && req.size > 0 ? req.size : undefined;
113
129
  const chunkUploadUrls = await Promise.all(
114
130
  Array.from({ length: chunkCount }, (_, ci) =>
115
- cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner }),
131
+ cfg.mintChunkUpload({ key, sid, ci, tc: chunkCount, owner, sz: declaredSize }),
116
132
  ),
117
133
  );
118
134
  return { fileId, sid, key, chunkSize, chunkCount, chunkUploadUrls };