@cosmicdrift/kumiko-framework 0.88.0 → 0.90.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-framework",
3
- "version": "0.88.0",
3
+ "version": "0.90.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.88.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.90.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -41,6 +41,12 @@ describe("cacheControlHeader", () => {
41
41
  expect(cacheControlHeader({ kind: "revalidate" })).toBe("public, max-age=0, must-revalidate");
42
42
  });
43
43
 
44
+ test("revalidate with explicit max-age window", () => {
45
+ expect(cacheControlHeader({ kind: "revalidate", maxAgeSeconds: 60 })).toBe(
46
+ "public, max-age=60, must-revalidate",
47
+ );
48
+ });
49
+
44
50
  test("no-cache", () => {
45
51
  expect(cacheControlHeader({ kind: "no-cache" })).toBe("no-cache");
46
52
  });
@@ -147,4 +153,21 @@ describe("cachedResponse", () => {
147
153
  });
148
154
  expect(res.status).toBe(304);
149
155
  });
156
+
157
+ test("mismatching If-None-Match ignores If-Modified-Since (RFC 7232 §3.3)", () => {
158
+ const lastModified = new Date("2026-06-01T12:00:00.000Z");
159
+ const req = new Request("https://example.test/", {
160
+ headers: {
161
+ "if-none-match": '"stale"',
162
+ "if-modified-since": lastModified.toUTCString(),
163
+ },
164
+ });
165
+ const res = cachedResponse(req, {
166
+ body: "hello",
167
+ etag,
168
+ cache: { kind: "revalidate" },
169
+ lastModified,
170
+ });
171
+ expect(res.status).toBe(200);
172
+ });
150
173
  });
@@ -104,10 +104,12 @@ function buildResponseHeaders(init: CachedResponseInit): Record<string, string>
104
104
  export function cachedResponse(req: Request, init: CachedResponseInit): Response {
105
105
  const headers = buildResponseHeaders(init);
106
106
  const ifNoneMatch = req.headers.get("if-none-match");
107
- if (etagMatches(ifNoneMatch, init.etag)) {
108
- return new Response(null, { status: 304, headers });
109
- }
110
- if (init.lastModified !== undefined) {
107
+ // RFC 7232 §3.3: If-None-Match present → it alone decides; ignore If-Modified-Since.
108
+ if (ifNoneMatch !== null) {
109
+ if (etagMatches(ifNoneMatch, init.etag)) {
110
+ return new Response(null, { status: 304, headers });
111
+ }
112
+ } else if (init.lastModified !== undefined) {
111
113
  const ifModifiedSince = req.headers.get("if-modified-since");
112
114
  if (ifModifiedSince !== null && isNotModifiedSince(ifModifiedSince, init.lastModified)) {
113
115
  return new Response(null, { status: 304, headers });
@@ -34,6 +34,27 @@ export async function selectEventsForProjectionRebuildBatch(
34
34
  )) as ReadonlyArray<Record<string, unknown>>;
35
35
  }
36
36
 
37
+ // Total subscribed events in the log — same source/type filter as
38
+ // selectEventsForProjectionRebuildBatch, no id cursor. Under the cutover fence
39
+ // this is the ground-truth count the rebuild must have applied; a shortfall
40
+ // means a lower-id event committed late and the id-cursor leapt past it (#443).
41
+ export async function countSubscribedEvents(
42
+ db: AnyDb,
43
+ aggregateTypes: readonly string[],
44
+ eventTypes: readonly string[],
45
+ ): Promise<bigint> {
46
+ const rows = (await asRawClient(db).unsafe(
47
+ `SELECT count(*)::bigint AS n FROM "kumiko_events"
48
+ WHERE "aggregate_type" = ANY($1::text[])
49
+ AND "type" = ANY($2::text[])`,
50
+ [aggregateTypes, eventTypes],
51
+ )) as ReadonlyArray<{ n: bigint | string | number | null }>;
52
+ const raw = rows[0]?.n;
53
+ if (typeof raw === "bigint") return raw;
54
+ if (raw === null || raw === undefined) return 0n;
55
+ return BigInt(raw);
56
+ }
57
+
37
58
  export async function finalizeProjectionRebuild(
38
59
  db: AnyDb,
39
60
  projectionName: string,
@@ -698,13 +698,13 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
698
698
  expect(await getCount(group)).toBe(3);
699
699
  });
700
700
 
701
- test("KNOWN LIMITATION (#443): a lower-id write committed late during replay is currently LOST", async () => {
701
+ test("(#443) a lower-id write committed late during replay is recovered under the fence", async () => {
702
702
  // bigserial assigns ids at INSERT (pre-commit); a cross-aggregate write can
703
703
  // commit an id BELOW the cursor the unlocked drain already advanced past, and
704
- // the fenced final drain (`WHERE id > cursor`) never revisits it. This
705
- // characterization test pins that data-loss deterministically. No clean fix
706
- // preserves the online (short-fence) property see #443. When the fix lands,
707
- // flip groupX's assertion to toBe(1).
704
+ // the fenced final drain (`WHERE id > cursor`) never revisits it. Under the
705
+ // fence the subscribed-event set is final, so a count re-check detects the
706
+ // shortfall and re-replays the full log into a fresh shadow — groupX, whose
707
+ // low-id event committed late, is no longer lost. See #443.
708
708
  const db = testDb.db as DbConnection; // @cast-boundary test-harness (TestDb.db is intentionally unknown)
709
709
  const groupX = "00000000-0000-4000-8000-000000000201";
710
710
  const groupY = "00000000-0000-4000-8000-000000000202";
@@ -747,7 +747,7 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
747
747
  });
748
748
 
749
749
  expect(await getCount(groupY)).toBe(1);
750
- expect(await getCount(groupX)).toBeUndefined(); // BUG (#443): should be 1
750
+ expect(await getCount(groupX)).toBe(1); // #443 fixed: fenced count re-check re-replayed the missed low-id event
751
751
  });
752
752
 
753
753
  test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
@@ -1,5 +1,6 @@
1
1
  import type { DbConnection, DbTx } from "../db/connection";
2
2
  import {
3
+ countSubscribedEvents,
3
4
  finalizeProjectionRebuild,
4
5
  markProjectionRebuildFailed,
5
6
  markProjectionRebuilding,
@@ -110,10 +111,11 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
110
111
  // - The shadow is rebuilt from EntityTableMeta, so an index hand-added in a
111
112
  // migration but absent from meta is not reconstructed.
112
113
  // - Requires CREATE privilege to provision the shared rebuild schema.
113
- // - A cross-aggregate write that COMMITS with an event id below the cursor
114
- // after the cursor already passed it is lost from the rebuild (id-order
115
- // != commit-order; bigserial assigns ids pre-commit). Only reachable
116
- // during a concurrent rebuild; recovered by the next rebuild. #443.
114
+ // - id-order != commit-order (bigserial assigns ids pre-commit), so a
115
+ // cross-aggregate write can COMMIT an event id BELOW the cursor after the
116
+ // unlocked drain already passed it. Caught under the fence: a count
117
+ // re-check against the (now final) event set detects the shortfall and
118
+ // re-replays the full log into a fresh shadow. #443.
117
119
 
118
120
  export type RebuildResult = {
119
121
  readonly projection: string;
@@ -240,6 +242,23 @@ export async function rebuildProjection(
240
242
  while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
241
243
  // keep draining full batches; a short batch ends the loop
242
244
  }
245
+
246
+ // Fenced → the subscribed-event set is final (every live apply blocks
247
+ // on the live-table lock). If fewer events were applied than exist, a
248
+ // lower-id event committed late during the unlocked phase and the
249
+ // id-cursor leapt past it (#443). Rebuild the shadow from scratch and
250
+ // replay the whole log under the fence: a fresh shadow means no
251
+ // double-apply (the rejected "re-drain into a populated shadow" hazard),
252
+ // and the full-replay cost is paid only on this rare detected path.
253
+ const expectedEvents = await countSubscribedEvents(tx, sourcesList, subscribedList);
254
+ if (expectedEvents > BigInt(eventsProcessed)) {
255
+ await buildShadowTable(tx, meta);
256
+ lastProcessedEventId = 0n;
257
+ eventsProcessed = 0;
258
+ while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
259
+ // re-replay the full log into the fresh shadow
260
+ }
261
+ }
243
262
  }
244
263
 
245
264
  await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);