@cosmicdrift/kumiko-framework 0.87.3 → 0.89.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.
|
|
3
|
+
"version": "0.89.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.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.89.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
|
});
|
package/src/api/http-cache.ts
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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("
|
|
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.
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
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)).
|
|
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
|
-
// -
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
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);
|