@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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 +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Dead-letter storage for apply-handler failures during projection rebuild.
|
|
2
|
+
//
|
|
3
|
+
// Background: rebuildProjection / rebuildMultiStreamProjection replay the
|
|
4
|
+
// event log through apply handlers inside ONE transaction. A single event
|
|
5
|
+
// whose (possibly years-old) payload makes its apply throw rolls the whole
|
|
6
|
+
// replay back — the projection stays permanently un-rebuildable until the
|
|
7
|
+
// event is repaired by hand. The upcaster dead-letter only quarantines
|
|
8
|
+
// upcast-TRANSFORM failures, not apply failures (#760).
|
|
9
|
+
//
|
|
10
|
+
// Quarantine mode (errorPolicy.skipApplyErrors on RebuildDeps, or
|
|
11
|
+
// MspErrorMode.rebuild.skipApplyErrors on the MSP definition) confines each
|
|
12
|
+
// apply to a savepoint: a throwing apply is rolled back, captured into
|
|
13
|
+
// `kumiko_rebuild_dead_letters`, and the replay continues. Replay-after-fix
|
|
14
|
+
// is a separate ops step — same stance as the upcaster dead-letter.
|
|
15
|
+
|
|
16
|
+
import type { DbConnection, DbRunner } from "../db/connection";
|
|
17
|
+
import { bigint, index, jsonb, table as pgTable, text, timestamp, uuid } from "../db/dialect";
|
|
18
|
+
import { tableExists } from "../db/schema-inspection";
|
|
19
|
+
import { unsafePushTables } from "../stack";
|
|
20
|
+
import type { StoredEvent } from "./event-store";
|
|
21
|
+
|
|
22
|
+
export const rebuildDeadLetterTable = pgTable(
|
|
23
|
+
"kumiko_rebuild_dead_letters",
|
|
24
|
+
{
|
|
25
|
+
// Surrogate PK — the same event can land here across multiple rebuild
|
|
26
|
+
// attempts before the fix ships, without unique-violation noise.
|
|
27
|
+
id: bigint("id", { mode: "bigint" }).primaryKey().generatedAlwaysAsIdentity(),
|
|
28
|
+
projectionName: text("projection_name").notNull(),
|
|
29
|
+
// StoredEvent.id is surfaced as `string` (bigint serialised for JSON
|
|
30
|
+
// safety) — stored as text to keep the round-trip identity.
|
|
31
|
+
eventId: text("event_id").notNull(),
|
|
32
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
33
|
+
aggregateId: text("aggregate_id").notNull(),
|
|
34
|
+
aggregateType: text("aggregate_type").notNull(),
|
|
35
|
+
eventType: text("event_type").notNull(),
|
|
36
|
+
errorMessage: text("error_message").notNull(),
|
|
37
|
+
payload: jsonb("payload").notNull(),
|
|
38
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
(t) => ({
|
|
41
|
+
projectionIdx: index("rebuild_dead_letters_projection_idx").on(t.projectionName),
|
|
42
|
+
createdAtIdx: index("rebuild_dead_letters_created_at_idx").on(t.createdAt),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Idempotent table-create. The rebuild runners call this before the rebuild
|
|
47
|
+
// tx whenever quarantine mode is active, so the opt-in works on databases
|
|
48
|
+
// provisioned before this table existed.
|
|
49
|
+
export async function createRebuildDeadLetterTable(db: DbConnection): Promise<void> {
|
|
50
|
+
// skip: table already exists — bootstrap called from multiple paths
|
|
51
|
+
if (await tableExists(db, "public.kumiko_rebuild_dead_letters")) return;
|
|
52
|
+
await unsafePushTables(db, { kumikoRebuildDeadLetters: rebuildDeadLetterTable });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type SkippedApply = {
|
|
56
|
+
readonly event: StoredEvent;
|
|
57
|
+
readonly error: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Bulk-write, called ONCE at the end of a quarantining rebuild (inside its
|
|
61
|
+
// tx). Unqualified insert — during the rebuild the search_path points at the
|
|
62
|
+
// shadow schema, and this table exists only in public, so it falls through.
|
|
63
|
+
export async function recordRebuildDeadLetters(
|
|
64
|
+
db: DbRunner,
|
|
65
|
+
projectionName: string,
|
|
66
|
+
skipped: readonly SkippedApply[],
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const { insertMany } = await import("../bun-db/query");
|
|
69
|
+
await insertMany(
|
|
70
|
+
db,
|
|
71
|
+
rebuildDeadLetterTable,
|
|
72
|
+
skipped.map(({ event, error }) => ({
|
|
73
|
+
projectionName,
|
|
74
|
+
eventId: event.id,
|
|
75
|
+
tenantId: event.tenantId,
|
|
76
|
+
aggregateId: event.aggregateId,
|
|
77
|
+
aggregateType: event.aggregateType,
|
|
78
|
+
eventType: event.type,
|
|
79
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
80
|
+
payload: event.payload,
|
|
81
|
+
})),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RebuildDeadLetterRow = {
|
|
86
|
+
readonly id: bigint;
|
|
87
|
+
readonly projectionName: string;
|
|
88
|
+
readonly eventId: string;
|
|
89
|
+
readonly tenantId: string;
|
|
90
|
+
readonly aggregateId: string;
|
|
91
|
+
readonly aggregateType: string;
|
|
92
|
+
readonly eventType: string;
|
|
93
|
+
readonly errorMessage: string;
|
|
94
|
+
readonly payload: Record<string, unknown>;
|
|
95
|
+
readonly createdAt: Date;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Ops-side triage query, optionally scoped to one projection.
|
|
99
|
+
export async function listRebuildDeadLetters(
|
|
100
|
+
db: DbConnection,
|
|
101
|
+
options: { projectionName?: string; limit?: number } = {},
|
|
102
|
+
): Promise<readonly RebuildDeadLetterRow[]> {
|
|
103
|
+
const { selectMany } = await import("../bun-db/query");
|
|
104
|
+
const limit = options.limit ?? 100;
|
|
105
|
+
const where =
|
|
106
|
+
options.projectionName !== undefined ? { projectionName: options.projectionName } : undefined;
|
|
107
|
+
return selectMany<RebuildDeadLetterRow>(db, rebuildDeadLetterTable, where, {
|
|
108
|
+
orderBy: { col: "createdAt", direction: "desc" },
|
|
109
|
+
limit,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
// Bun.SQL-only setup. KEIN postgres-js, KEIN setupTestStack.
|
|
10
10
|
|
|
11
11
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
12
13
|
import { z } from "zod";
|
|
13
14
|
import type { DbConnection, DbTx } from "../../db/connection";
|
|
14
15
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
15
|
-
import { insertOne } from "../../db/query";
|
|
16
16
|
import { buildEntityTable } from "../../db/table-builder";
|
|
17
17
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
18
18
|
import { append, loadAggregate as loadAggregateRaw } from "../../event-store";
|
|
@@ -219,7 +219,7 @@ describe("ctx.loadAggregate via queryHandler — Marten AggregateStreamAsync equ
|
|
|
219
219
|
// produce garbage.
|
|
220
220
|
const invoiceId = "00000000-0000-4000-8000-000000000042";
|
|
221
221
|
await (stack.db as DbConnection).begin(async (tx: DbTx) => {
|
|
222
|
-
await
|
|
222
|
+
await seedRow(tx, invoiceTable, {
|
|
223
223
|
id: invoiceId,
|
|
224
224
|
tenantId: admin.tenantId,
|
|
225
225
|
customer: "LegacyCo",
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// #760 — poison-event quarantine during projection rebuild.
|
|
2
|
+
//
|
|
3
|
+
// A single historical event whose apply throws used to abort the WHOLE
|
|
4
|
+
// rebuild (tx rollback, status failed) with no recovery path short of
|
|
5
|
+
// hand-editing the event. With quarantine mode the rebuild confines each
|
|
6
|
+
// apply to a savepoint: the poison event is skipped, recorded into
|
|
7
|
+
// kumiko_rebuild_dead_letters, and the replay completes.
|
|
8
|
+
//
|
|
9
|
+
// Covers both rebuild flavors:
|
|
10
|
+
// - single-stream: RebuildDeps.errorPolicy.skipApplyErrors (per run)
|
|
11
|
+
// - MSP: MspErrorMode.rebuild.skipApplyErrors (per definition — declared
|
|
12
|
+
// API that was previously never honored by rebuildMultiStreamProjection)
|
|
13
|
+
// and both poison flavors:
|
|
14
|
+
// - JS throw (no SQL executed)
|
|
15
|
+
// - SQL error (aborts the tx → proves the savepoint is load-bearing)
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { integer as pgInteger, table as pgTable, uuid as pgUuid } from "../../db/dialect";
|
|
20
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
21
|
+
import { asRawClient } from "../../db/query";
|
|
22
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
23
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
24
|
+
import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
|
|
25
|
+
import type { ProjectionDefinition } from "../../engine/types";
|
|
26
|
+
import { append, createEventsTable } from "../../event-store";
|
|
27
|
+
import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
|
|
28
|
+
import {
|
|
29
|
+
createProjectionStateTable,
|
|
30
|
+
getConsumerState,
|
|
31
|
+
getProjectionState,
|
|
32
|
+
rebuildMultiStreamProjection,
|
|
33
|
+
rebuildProjection,
|
|
34
|
+
} from "../../pipeline";
|
|
35
|
+
import {
|
|
36
|
+
setupTestStack,
|
|
37
|
+
type TestStack,
|
|
38
|
+
TestUsers,
|
|
39
|
+
unsafeCreateEntityTable,
|
|
40
|
+
unsafePushTables,
|
|
41
|
+
} from "../../stack";
|
|
42
|
+
|
|
43
|
+
// --- Fixtures ---
|
|
44
|
+
|
|
45
|
+
const POISON_JS = "poison-js";
|
|
46
|
+
const POISON_SQL = "poison-sql";
|
|
47
|
+
|
|
48
|
+
const itemEntity = createEntity({
|
|
49
|
+
table: "read_poison_items",
|
|
50
|
+
fields: {
|
|
51
|
+
groupId: createTextField({ required: true }),
|
|
52
|
+
name: createTextField({ required: true }),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const itemTable = buildEntityTable("poison-item", itemEntity);
|
|
56
|
+
|
|
57
|
+
const counterTable = pgTable("read_poison_counter", {
|
|
58
|
+
groupId: pgUuid("group_id").primaryKey(),
|
|
59
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
60
|
+
itemCount: pgInteger("item_count").notNull().default(0),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const mspCounterTable = pgTable("read_poison_msp_counter", {
|
|
64
|
+
groupId: pgUuid("group_id").primaryKey(),
|
|
65
|
+
tenantId: pgUuid("tenant_id").notNull(),
|
|
66
|
+
itemCount: pgInteger("item_count").notNull().default(0),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function bump(tx: unknown, tableName: string, groupId: string, tenantId: string) {
|
|
70
|
+
await asRawClient(tx).unsafe(
|
|
71
|
+
`INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = "${tableName}".item_count + 1`,
|
|
72
|
+
[groupId, tenantId],
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Poison by payload marker. POISON_SQL runs a genuinely failing statement so
|
|
77
|
+
// the surrounding tx would be in 25P02 without the savepoint.
|
|
78
|
+
async function applyOrPoison(
|
|
79
|
+
tx: unknown,
|
|
80
|
+
tableName: string,
|
|
81
|
+
payload: { groupId: string; name: string },
|
|
82
|
+
tenantId: string,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
if (payload.name === POISON_JS) throw new Error("intentional js poison");
|
|
85
|
+
if (payload.name === POISON_SQL) {
|
|
86
|
+
await asRawClient(tx).unsafe(
|
|
87
|
+
`INSERT INTO "${tableName}" (group_id, tenant_id, item_count) VALUES ('not-a-uuid', $1::uuid, 1)`,
|
|
88
|
+
[tenantId],
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await bump(tx, tableName, payload.groupId, tenantId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
type ItemPayload = { groupId: string; name: string };
|
|
96
|
+
|
|
97
|
+
const poisonProjection: ProjectionDefinition = {
|
|
98
|
+
name: "poison-counter",
|
|
99
|
+
source: "poison-item",
|
|
100
|
+
table: counterTable,
|
|
101
|
+
apply: {
|
|
102
|
+
"poison-item.created": defineApply<ItemPayload>(async (event, tx) => {
|
|
103
|
+
await applyOrPoison(tx, "read_poison_counter", event.payload, event.tenantId);
|
|
104
|
+
}),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const MSP_EVENT_SHORT = "poison-noted";
|
|
109
|
+
|
|
110
|
+
const feature = defineFeature("poisontest", (r) => {
|
|
111
|
+
r.entity("poison-item", itemEntity);
|
|
112
|
+
r.projection(poisonProjection);
|
|
113
|
+
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }));
|
|
114
|
+
r.multiStreamProjection({
|
|
115
|
+
name: "poison-msp-counter",
|
|
116
|
+
table: mspCounterTable,
|
|
117
|
+
errorMode: { rebuild: { skipApplyErrors: true } },
|
|
118
|
+
apply: {
|
|
119
|
+
[noted.name]: async (event, tx) => {
|
|
120
|
+
const p = event.payload as ItemPayload; // @cast-boundary engine-payload
|
|
121
|
+
await applyOrPoison(tx, "read_poison_msp_counter", p, event.tenantId);
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const admin = TestUsers.admin;
|
|
128
|
+
const PROJECTION = "poisontest:projection:poison-counter";
|
|
129
|
+
const MSP = "poisontest:projection:poison-msp-counter";
|
|
130
|
+
const MSP_EVENT = "poisontest:event:poison-noted";
|
|
131
|
+
const GROUP = "00000000-0000-4000-8000-00000000cafe";
|
|
132
|
+
|
|
133
|
+
let stack: TestStack;
|
|
134
|
+
let tdb: TenantDb;
|
|
135
|
+
|
|
136
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "poison-item" });
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
stack = await setupTestStack({ features: [feature] });
|
|
140
|
+
await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
|
|
141
|
+
await createEventsTable(stack.db);
|
|
142
|
+
await createProjectionStateTable(stack.db);
|
|
143
|
+
await unsafePushTables(stack.db, {
|
|
144
|
+
poisonCounter: counterTable,
|
|
145
|
+
poisonMspCounter: mspCounterTable,
|
|
146
|
+
});
|
|
147
|
+
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterAll(async () => {
|
|
151
|
+
await stack.cleanup();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
beforeEach(async () => {
|
|
155
|
+
await asRawClient(stack.db).unsafe(
|
|
156
|
+
`TRUNCATE kumiko_events, read_poison_items, read_poison_counter, read_poison_msp_counter, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
157
|
+
);
|
|
158
|
+
await asRawClient(stack.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild_dead_letters`);
|
|
159
|
+
if (stack.eventDispatcher) await stack.eventDispatcher.ensureRegistered();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
async function createItem(name: string): Promise<void> {
|
|
163
|
+
await executor.create({ groupId: GROUP, name }, admin, tdb);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function getCount(table: string): Promise<number | undefined> {
|
|
167
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
168
|
+
`SELECT item_count FROM "${table}" WHERE group_id = $1::uuid`,
|
|
169
|
+
[GROUP],
|
|
170
|
+
)) as ReadonlyArray<{ item_count: number }>;
|
|
171
|
+
return rows[0]?.item_count;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
describe("rebuildProjection — poison event, strict default (#760)", () => {
|
|
175
|
+
test("apply throw aborts the rebuild: status failed, no dead letters", async () => {
|
|
176
|
+
await createItem("good-1");
|
|
177
|
+
await createItem(POISON_JS);
|
|
178
|
+
await createItem("good-2");
|
|
179
|
+
|
|
180
|
+
await expect(
|
|
181
|
+
rebuildProjection(PROJECTION, { db: stack.db, registry: stack.registry }),
|
|
182
|
+
).rejects.toThrow("intentional js poison");
|
|
183
|
+
|
|
184
|
+
const state = await getProjectionState(stack.db, PROJECTION);
|
|
185
|
+
expect(state?.status).toBe("failed");
|
|
186
|
+
// Strict mode never provisions/records dead letters.
|
|
187
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
188
|
+
`SELECT to_regclass('public.kumiko_rebuild_dead_letters') AS t`,
|
|
189
|
+
)) as ReadonlyArray<{ t: string | null }>;
|
|
190
|
+
expect(rows[0]?.t).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("rebuildProjection — quarantine mode (#760)", () => {
|
|
195
|
+
test("js- and sql-poison events are skipped, recorded, and the rebuild completes", async () => {
|
|
196
|
+
await createItem("good-1");
|
|
197
|
+
await createItem(POISON_JS);
|
|
198
|
+
// sql-poison proves the savepoint: without it the failed statement puts
|
|
199
|
+
// the whole rebuild tx into 25P02 and every later apply fails too.
|
|
200
|
+
await createItem(POISON_SQL);
|
|
201
|
+
await createItem("good-2");
|
|
202
|
+
|
|
203
|
+
const result = await rebuildProjection(PROJECTION, {
|
|
204
|
+
db: stack.db,
|
|
205
|
+
registry: stack.registry,
|
|
206
|
+
errorPolicy: { skipApplyErrors: true },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(result.eventsSkipped).toBe(2);
|
|
210
|
+
expect(result.eventsProcessed).toBe(4);
|
|
211
|
+
expect(await getCount("read_poison_counter")).toBe(2);
|
|
212
|
+
|
|
213
|
+
const state = await getProjectionState(stack.db, PROJECTION);
|
|
214
|
+
expect(state?.status).toBe("idle");
|
|
215
|
+
|
|
216
|
+
const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION });
|
|
217
|
+
expect(deadLetters).toHaveLength(2);
|
|
218
|
+
const messages = deadLetters.map((d) => d.errorMessage).sort();
|
|
219
|
+
expect(messages[0]).toContain("poison");
|
|
220
|
+
expect(deadLetters.every((d) => d.eventType === "poison-item.created")).toBe(true);
|
|
221
|
+
expect(deadLetters.every((d) => d.aggregateType === "poison-item")).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("clean run in quarantine mode: zero skipped, no dead letters", async () => {
|
|
225
|
+
await createItem("good-1");
|
|
226
|
+
await createItem("good-2");
|
|
227
|
+
|
|
228
|
+
const result = await rebuildProjection(PROJECTION, {
|
|
229
|
+
db: stack.db,
|
|
230
|
+
registry: stack.registry,
|
|
231
|
+
errorPolicy: { skipApplyErrors: true },
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(result.eventsSkipped).toBe(0);
|
|
235
|
+
expect(await getCount("read_poison_counter")).toBe(2);
|
|
236
|
+
expect(await listRebuildDeadLetters(stack.db, { projectionName: PROJECTION })).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("rebuildMultiStreamProjection — errorMode.rebuild.skipApplyErrors (#760)", () => {
|
|
241
|
+
async function appendMspEvent(name: string, expectedVersion: number): Promise<void> {
|
|
242
|
+
await append(stack.db, {
|
|
243
|
+
aggregateId: GROUP,
|
|
244
|
+
aggregateType: "poison-note",
|
|
245
|
+
tenantId: admin.tenantId,
|
|
246
|
+
expectedVersion,
|
|
247
|
+
type: MSP_EVENT,
|
|
248
|
+
payload: { groupId: GROUP, name },
|
|
249
|
+
metadata: { userId: admin.id },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
test("declared rebuild policy is honored: poison skipped + recorded, cursor advanced", async () => {
|
|
254
|
+
await appendMspEvent("good-1", 0);
|
|
255
|
+
await appendMspEvent(POISON_SQL, 1);
|
|
256
|
+
await appendMspEvent("good-2", 2);
|
|
257
|
+
|
|
258
|
+
const result = await rebuildMultiStreamProjection(MSP, {
|
|
259
|
+
db: stack.db,
|
|
260
|
+
registry: stack.registry,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(result.eventsSkipped).toBe(1);
|
|
264
|
+
expect(result.eventsProcessed).toBe(3);
|
|
265
|
+
expect(await getCount("read_poison_msp_counter")).toBe(2);
|
|
266
|
+
|
|
267
|
+
const deadLetters = await listRebuildDeadLetters(stack.db, { projectionName: MSP });
|
|
268
|
+
expect(deadLetters).toHaveLength(1);
|
|
269
|
+
expect(deadLetters[0]?.eventType).toBe(MSP_EVENT);
|
|
270
|
+
|
|
271
|
+
const consumer = await getConsumerState(stack.db, MSP);
|
|
272
|
+
expect(consumer?.lastProcessedEventId).toBe(result.lastProcessedEventId);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { requestContext } from "../api/request-context";
|
|
2
2
|
import type { DbConnection, DbRow, DbTx } from "../db/connection";
|
|
3
3
|
import { selectRowForUpdateById } from "../db/queries/entity-read";
|
|
4
|
-
import { selectMany, transaction } from "../db/query";
|
|
4
|
+
import { asEntityTableMeta, selectMany, transaction } from "../db/query";
|
|
5
5
|
import { buildEntityTable, toSnakeCase } from "../db/table-builder";
|
|
6
6
|
import { createTenantDb } from "../db/tenant-db";
|
|
7
7
|
import { hasAccess } from "../engine/access";
|
|
@@ -717,12 +717,8 @@ export function createDispatcher(
|
|
|
717
717
|
user,
|
|
718
718
|
ip: reqCtx?.ip,
|
|
719
719
|
});
|
|
720
|
-
// skip: ip-
|
|
721
|
-
//
|
|
722
|
-
// through BEFORE requiring a resolver, so system/seed writes through
|
|
723
|
-
// such a handler don't need a RateLimitResolver wired (the es-ops
|
|
724
|
-
// seed dispatcher has none). L1/L2 middleware handle the HTTP-side
|
|
725
|
-
// ip caps.
|
|
720
|
+
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
721
|
+
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
726
722
|
if (bucket.kind === "skip") return;
|
|
727
723
|
if (!context.rateLimit) {
|
|
728
724
|
throw new InternalError({
|
|
@@ -1162,9 +1158,7 @@ export function createDispatcher(
|
|
|
1162
1158
|
// can false-pass; optimistic locking would catch it later, but with
|
|
1163
1159
|
// a less specific error. Falls back to a plain SELECT if no tx is
|
|
1164
1160
|
// active (tests without a DB connection).
|
|
1165
|
-
const tableName =
|
|
1166
|
-
(table as { [key: symbol]: unknown })[Symbol.for("kumiko:schema:Name")],
|
|
1167
|
-
);
|
|
1161
|
+
const tableName = asEntityTableMeta(table)?.tableName ?? "";
|
|
1168
1162
|
const rows = tx
|
|
1169
1163
|
? await selectRowForUpdateById(handlerContext.db, tableName, id)
|
|
1170
1164
|
: await selectMany(handlerContext.db, table, { id });
|
|
@@ -11,11 +11,16 @@ import {
|
|
|
11
11
|
rebuildMetaOrThrow,
|
|
12
12
|
swapShadowIntoLive,
|
|
13
13
|
} from "../db/queries/shadow-swap";
|
|
14
|
-
import { selectMany } from "../db/query";
|
|
14
|
+
import { runInSavepoint, selectMany } from "../db/query";
|
|
15
15
|
import type { Registry, TenantId } from "../engine/types";
|
|
16
16
|
import { InternalError } from "../errors";
|
|
17
17
|
import { eventsTable, type StoredEvent, upcastStoredEvent } from "../event-store";
|
|
18
18
|
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store";
|
|
19
|
+
import {
|
|
20
|
+
createRebuildDeadLetterTable,
|
|
21
|
+
recordRebuildDeadLetters,
|
|
22
|
+
type SkippedApply,
|
|
23
|
+
} from "../event-store/rebuild-dead-letter";
|
|
19
24
|
import { upcastStoredEvents } from "../event-store/upcaster";
|
|
20
25
|
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
21
26
|
import type { Meter } from "../observability/types/metric";
|
|
@@ -123,12 +128,21 @@ export async function rebuildMultiStreamProjection(
|
|
|
123
128
|
|
|
124
129
|
const meta = rebuildMetaOrThrow(msp.table, mspName);
|
|
125
130
|
|
|
131
|
+
// Declared per-projection (MspErrorMode): the rebuild policy falls back to
|
|
132
|
+
// continuous when omitted — previously declared but never honored here.
|
|
133
|
+
const skipApplyErrors =
|
|
134
|
+
(msp.errorMode?.rebuild ?? msp.errorMode?.continuous)?.skipApplyErrors === true;
|
|
135
|
+
|
|
126
136
|
const startedAt = Date.now();
|
|
127
137
|
let eventsProcessed = 0;
|
|
128
138
|
let lastProcessedEventId = 0n;
|
|
139
|
+
const skipped: SkippedApply[] = [];
|
|
129
140
|
|
|
130
141
|
try {
|
|
131
142
|
await ensureRebuildSchema(db);
|
|
143
|
+
// Outside the rebuild tx, like the schema: idempotent DDL colliding
|
|
144
|
+
// inside the tx would roll the whole replay back.
|
|
145
|
+
if (skipApplyErrors) await createRebuildDeadLetterTable(db);
|
|
132
146
|
await db.begin(async (tx: DbTx) => {
|
|
133
147
|
await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
134
148
|
await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
@@ -178,13 +192,31 @@ export async function rebuildMultiStreamProjection(
|
|
|
178
192
|
});
|
|
179
193
|
const applyFn = msp.apply[row.type];
|
|
180
194
|
if (!applyFn) continue;
|
|
181
|
-
|
|
182
|
-
|
|
195
|
+
if (skipApplyErrors) {
|
|
196
|
+
// Driver-native savepoint: a throwing SQL statement would
|
|
197
|
+
// otherwise poison the rebuild tx (25P02) AND make the driver
|
|
198
|
+
// reject the whole begin() even after a caught error. Apply +
|
|
199
|
+
// ctx run on the savepoint-scoped handle.
|
|
200
|
+
try {
|
|
201
|
+
await runInSavepoint(tx, async (sp) => {
|
|
202
|
+
const rebuildCtx = createRebuildCtx(registry, sp as DbRunner, row.tenantId);
|
|
203
|
+
await applyFn(storedEvent, sp as DbRunner, rebuildCtx);
|
|
204
|
+
});
|
|
205
|
+
} catch (e) {
|
|
206
|
+
skipped.push({ event: storedEvent, error: e });
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const rebuildCtx = createRebuildCtx(registry, tx, row.tenantId);
|
|
210
|
+
await applyFn(storedEvent, tx, rebuildCtx);
|
|
211
|
+
}
|
|
183
212
|
eventsProcessed++;
|
|
184
213
|
lastProcessedEventId = row.id;
|
|
185
214
|
}
|
|
186
215
|
}
|
|
187
216
|
|
|
217
|
+
if (skipped.length > 0) {
|
|
218
|
+
await recordRebuildDeadLetters(tx, mspName, skipped);
|
|
219
|
+
}
|
|
188
220
|
await updateConsumerRebuildCursor(
|
|
189
221
|
tx,
|
|
190
222
|
mspName,
|
|
@@ -210,6 +242,7 @@ export async function rebuildMultiStreamProjection(
|
|
|
210
242
|
const result: RebuildResult = {
|
|
211
243
|
projection: mspName,
|
|
212
244
|
eventsProcessed,
|
|
245
|
+
eventsSkipped: skipped.length,
|
|
213
246
|
lastProcessedEventId,
|
|
214
247
|
durationMs: Date.now() - startedAt,
|
|
215
248
|
};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
rebuildMetaOrThrow,
|
|
14
14
|
swapShadowIntoLive,
|
|
15
15
|
} from "../db/queries/shadow-swap";
|
|
16
|
-
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
16
|
+
import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query";
|
|
17
17
|
import type { Registry, TenantId } from "../engine/types";
|
|
18
18
|
import {
|
|
19
19
|
eventsTable,
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
upcastStoredEvent,
|
|
23
23
|
} from "../event-store";
|
|
24
24
|
import type { EventMetadata } from "../event-store/event-store";
|
|
25
|
+
import {
|
|
26
|
+
createRebuildDeadLetterTable,
|
|
27
|
+
recordRebuildDeadLetters,
|
|
28
|
+
type SkippedApply,
|
|
29
|
+
} from "../event-store/rebuild-dead-letter";
|
|
25
30
|
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
26
31
|
import type { Meter } from "../observability/types/metric";
|
|
27
32
|
import { projectionStateTable } from "./projection-state";
|
|
@@ -119,7 +124,12 @@ function rowToStoredEvent(row: StoredEventRow): StoredEvent {
|
|
|
119
124
|
|
|
120
125
|
export type RebuildResult = {
|
|
121
126
|
readonly projection: string;
|
|
127
|
+
// Events consumed from the log — includes quarantined ones (the cursor
|
|
128
|
+
// advanced past them).
|
|
122
129
|
readonly eventsProcessed: number;
|
|
130
|
+
// Poison events quarantined into kumiko_rebuild_dead_letters this run.
|
|
131
|
+
// Always 0 unless quarantine mode was active (#760).
|
|
132
|
+
readonly eventsSkipped: number;
|
|
123
133
|
readonly lastProcessedEventId: bigint;
|
|
124
134
|
readonly durationMs: number;
|
|
125
135
|
};
|
|
@@ -145,6 +155,15 @@ type RebuildDeps = {
|
|
|
145
155
|
// on the live table; if a long-running writer holds it past this, the rebuild
|
|
146
156
|
// fails loud instead of hanging. Defaults to DEFAULT_FENCE_LOCK_TIMEOUT_MS.
|
|
147
157
|
readonly fenceLockTimeoutMs?: number;
|
|
158
|
+
// Apply-error policy for THIS run. Default strict: the first throwing
|
|
159
|
+
// apply aborts the rebuild (tx rollback, status "failed") — a poison
|
|
160
|
+
// event makes the projection permanently un-rebuildable (#760).
|
|
161
|
+
// skipApplyErrors: true confines every apply to a savepoint; a throwing
|
|
162
|
+
// apply is rolled back, recorded into kumiko_rebuild_dead_letters and
|
|
163
|
+
// skipped, and the rebuild completes without its effect. Opt-in for
|
|
164
|
+
// operators unblocking a pinned rebuild — the projection then knowingly
|
|
165
|
+
// misses the quarantined events until they are repaired and replayed.
|
|
166
|
+
readonly errorPolicy?: { readonly skipApplyErrors?: boolean };
|
|
148
167
|
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
149
168
|
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
150
169
|
// replay window deterministically. The `__test_` prefix marks it test-only:
|
|
@@ -174,7 +193,7 @@ export async function rebuildProjection(
|
|
|
174
193
|
const meta = rebuildMetaOrThrow(projection.table, projectionName);
|
|
175
194
|
|
|
176
195
|
const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
|
|
177
|
-
const sourcesList = [...sources];
|
|
196
|
+
const sourcesList = [...sources, ...(projection.extraSources ?? [])];
|
|
178
197
|
const subscribedList = Object.keys(projection.apply);
|
|
179
198
|
// Upcasters run at read time: older stored payloads get walked through the
|
|
180
199
|
// registered r.eventMigration chain until their shape matches the current
|
|
@@ -185,6 +204,11 @@ export async function rebuildProjection(
|
|
|
185
204
|
const startedAt = Date.now();
|
|
186
205
|
let eventsProcessed = 0;
|
|
187
206
|
let lastProcessedEventId = 0n;
|
|
207
|
+
const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
|
|
208
|
+
// Quarantined events, collected in memory and written once after the
|
|
209
|
+
// replay settles — the #443 full-re-replay path would otherwise duplicate
|
|
210
|
+
// the dead-letter rows.
|
|
211
|
+
let skipped: SkippedApply[] = [];
|
|
188
212
|
|
|
189
213
|
// One chronological batch of events after lastProcessedEventId, applied into
|
|
190
214
|
// the shadow. Returns the batch size so the caller can detect the tail
|
|
@@ -208,7 +232,21 @@ export async function rebuildProjection(
|
|
|
208
232
|
// skip: apply-key validation ensures every subscribed type has a handler;
|
|
209
233
|
// defensive check against runtime-mutated registry
|
|
210
234
|
if (!applyFn) continue;
|
|
211
|
-
|
|
235
|
+
if (skipApplyErrors) {
|
|
236
|
+
// Driver-native savepoint: a throwing SQL statement would otherwise
|
|
237
|
+
// poison the rebuild tx (25P02) AND make the driver reject the whole
|
|
238
|
+
// begin() even after a caught error. The apply runs on the
|
|
239
|
+
// savepoint-scoped handle so its statements are confined.
|
|
240
|
+
try {
|
|
241
|
+
await runInSavepoint(tx, async (sp) => {
|
|
242
|
+
await applyFn(storedEvent, sp as DbTx, projection.table);
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
skipped.push({ event: storedEvent, error: e });
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
await applyFn(storedEvent, tx, projection.table);
|
|
249
|
+
}
|
|
212
250
|
eventsProcessed++;
|
|
213
251
|
lastProcessedEventId = row.id;
|
|
214
252
|
}
|
|
@@ -217,6 +255,9 @@ export async function rebuildProjection(
|
|
|
217
255
|
|
|
218
256
|
try {
|
|
219
257
|
await ensureRebuildSchema(db);
|
|
258
|
+
// Outside the rebuild tx, like the schema: idempotent DDL colliding
|
|
259
|
+
// inside the tx would roll the whole replay back.
|
|
260
|
+
if (skipApplyErrors) await createRebuildDeadLetterTable(db);
|
|
220
261
|
await db.begin(async (tx: DbTx) => {
|
|
221
262
|
await markProjectionRebuilding(tx, projectionName);
|
|
222
263
|
await buildShadowTable(tx, meta);
|
|
@@ -261,12 +302,16 @@ export async function rebuildProjection(
|
|
|
261
302
|
deps.__test_onBuildShadowTable?.();
|
|
262
303
|
lastProcessedEventId = 0n;
|
|
263
304
|
eventsProcessed = 0;
|
|
305
|
+
skipped = [];
|
|
264
306
|
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
265
307
|
// re-replay the full log into the fresh shadow
|
|
266
308
|
}
|
|
267
309
|
}
|
|
268
310
|
}
|
|
269
311
|
|
|
312
|
+
if (skipped.length > 0) {
|
|
313
|
+
await recordRebuildDeadLetters(tx, projectionName, skipped);
|
|
314
|
+
}
|
|
270
315
|
await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
|
|
271
316
|
await swapShadowIntoLive(tx, meta.tableName);
|
|
272
317
|
});
|
|
@@ -292,6 +337,7 @@ export async function rebuildProjection(
|
|
|
292
337
|
const result: RebuildResult = {
|
|
293
338
|
projection: projectionName,
|
|
294
339
|
eventsProcessed,
|
|
340
|
+
eventsSkipped: skipped.length,
|
|
295
341
|
lastProcessedEventId,
|
|
296
342
|
durationMs: Date.now() - startedAt,
|
|
297
343
|
};
|