cursedbelt-server 4.9.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
+ }