@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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__/jwt.test.ts +27 -0
- 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 +5 -5
- 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__/required-surface-keys.test.ts +134 -1
- 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/extensions/user-data.ts +6 -0
- 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/files/__tests__/storage-tracking.integration.test.ts +8 -0
- package/src/files/file-routes.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
- 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 +55 -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 +24 -2
- 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
|
@@ -199,6 +199,11 @@ export type NumberFieldDef = {
|
|
|
199
199
|
readonly sensitive?: boolean;
|
|
200
200
|
readonly default?: number;
|
|
201
201
|
readonly access?: FieldAccess;
|
|
202
|
+
// Write-boundary constraints (Zod-level, no migration/storage impact — the
|
|
203
|
+
// Postgres column stays a plain numeric). Opt-in, so existing entities are
|
|
204
|
+
// unaffected.
|
|
205
|
+
readonly min?: number;
|
|
206
|
+
readonly integer?: boolean;
|
|
202
207
|
} & PiiAnnotations;
|
|
203
208
|
|
|
204
209
|
/**
|
|
@@ -557,6 +562,13 @@ export type DerivedFieldDef = {
|
|
|
557
562
|
|
|
558
563
|
export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
|
|
559
564
|
|
|
565
|
+
/** Client-facing projection of DerivedFieldDef — `derive` is server-only and
|
|
566
|
+
* not JSON-safe (would trip the output-walk guard), so the browser schema
|
|
567
|
+
* only ever carries `valueType`. A real `Pick`, not a same-shape cast: TS
|
|
568
|
+
* itself proves `derive` isn't there instead of a `{ valueType } as
|
|
569
|
+
* DerivedFieldDef` cast lying about a field that's actually missing. */
|
|
570
|
+
export type ClientDerivedFieldDef = Pick<DerivedFieldDef, "valueType">;
|
|
571
|
+
|
|
560
572
|
// --- Entity ---
|
|
561
573
|
|
|
562
574
|
// --- State Transitions ---
|
|
@@ -40,6 +40,19 @@ export type SessionUser = {
|
|
|
40
40
|
// When present, middleware can validate that the sid is still alive before
|
|
41
41
|
// accepting the request (session revocation).
|
|
42
42
|
readonly sid?: string;
|
|
43
|
+
// Set ONLY when the request authenticated via a Personal Access Token
|
|
44
|
+
// (bearer, PAT_TOKEN_PREFIX). Absent for cookie/JWT logins, which stay
|
|
45
|
+
// unrestricted. `allowedQns` are the QN globs the token's granted scopes
|
|
46
|
+
// expand to; the API boundary (routes.ts) rejects any dispatch type not
|
|
47
|
+
// matched by one of them (fail-closed). `scopes` are the granted scope
|
|
48
|
+
// names, kept for audit/display only.
|
|
49
|
+
readonly pat?: {
|
|
50
|
+
// The token row id — the per-token key for PAT rate limiting. Not the
|
|
51
|
+
// secret; safe to carry on the principal.
|
|
52
|
+
readonly tokenId: string;
|
|
53
|
+
readonly scopes: readonly string[];
|
|
54
|
+
readonly allowedQns: readonly string[];
|
|
55
|
+
};
|
|
43
56
|
};
|
|
44
57
|
|
|
45
58
|
// --- Claim Keys (r.claimKey declarations) ---
|
|
@@ -83,6 +83,7 @@ export type {
|
|
|
83
83
|
AnyFileFieldDef,
|
|
84
84
|
BigIntFieldDef,
|
|
85
85
|
BooleanFieldDef,
|
|
86
|
+
ClientDerivedFieldDef,
|
|
86
87
|
DateFieldDef,
|
|
87
88
|
DecimalFieldDef,
|
|
88
89
|
DefaultCurrency,
|
|
@@ -195,6 +196,7 @@ export type { EntityId, TenantId } from "./identifiers";
|
|
|
195
196
|
export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./identifiers";
|
|
196
197
|
export type { NavDefinition } from "./nav";
|
|
197
198
|
export type {
|
|
199
|
+
EntityProjectionExtension,
|
|
198
200
|
MspErrorMode,
|
|
199
201
|
MspErrorPolicy,
|
|
200
202
|
MultiStreamApplyFn,
|
|
@@ -14,17 +14,23 @@ import type { RunIn } from "./config";
|
|
|
14
14
|
export type ProjectionTable = TableColumns<any>;
|
|
15
15
|
|
|
16
16
|
// Single-stream projection apply: runs inline in the write-TX of the event
|
|
17
|
-
// it projects. Gets the event
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
17
|
+
// it projects. Gets the event, the TX-scoped DbRunner, and the projection's
|
|
18
|
+
// own `table` (already erased to ProjectionTable). Write through THAT table —
|
|
19
|
+
// it is the only ES-blessed write into a managed projection outside the
|
|
20
|
+
// executor, and it is reachable only here (an arbitrary handler has no such
|
|
21
|
+
// arg), so the write-brand cannot be bypassed by closing over the branded
|
|
22
|
+
// table constant. Inline projections must not spawn further events (no ctx)
|
|
23
|
+
// because they run inside the command's transaction and the framework
|
|
24
|
+
// guarantees a single commit boundary per command.
|
|
21
25
|
//
|
|
22
26
|
// Generic über payload-shape. Default = Record<string, unknown> behält
|
|
23
27
|
// rückwärtskompatibles Verhalten; Konkrete Apply-Handler annotieren
|
|
24
|
-
// `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access.
|
|
28
|
+
// `SingleStreamApplyFn<MyPayload>` für typed event.payload-Access. Der
|
|
29
|
+
// `table`-Param ist additiv — 2-arg-Applies (event, tx) bleiben gültig.
|
|
25
30
|
export type SingleStreamApplyFn<TPayload = Record<string, unknown>> = (
|
|
26
31
|
event: StoredEvent<TPayload>,
|
|
27
32
|
tx: DbRunner,
|
|
33
|
+
table: ProjectionTable,
|
|
28
34
|
) => Promise<void>;
|
|
29
35
|
|
|
30
36
|
// Multi-stream projection apply: runs asynchronously via the event-dispatcher
|
|
@@ -45,6 +51,11 @@ export type ProjectionDefinition = {
|
|
|
45
51
|
// index projections so the executor doesn't scan all projections on every
|
|
46
52
|
// write.
|
|
47
53
|
readonly source: string | readonly string[];
|
|
54
|
+
// Additional aggregate-types whose events the rebuild replay must include
|
|
55
|
+
// beyond `source`. Fed by r.extendEntityProjection — an extension can react
|
|
56
|
+
// to events on foreign streams (e.g. "field-definition") while `source`
|
|
57
|
+
// keeps meaning "the owning entity" for consumers like soft-delete-cleanup.
|
|
58
|
+
readonly extraSources?: readonly string[];
|
|
48
59
|
// Drizzle-table the projection materializes into. User owns the schema —
|
|
49
60
|
// framework just guarantees the TX and event delivery.
|
|
50
61
|
readonly table: ProjectionTable;
|
|
@@ -61,6 +72,23 @@ export type ProjectionDefinition = {
|
|
|
61
72
|
readonly isImplicit?: boolean;
|
|
62
73
|
};
|
|
63
74
|
|
|
75
|
+
// Extension merged into an entity's implicit projection at registry build.
|
|
76
|
+
// Lets a bundled feature that writes into the HOST entity's table via events
|
|
77
|
+
// (custom-fields pattern) hook those event types into the entity's rebuild
|
|
78
|
+
// replay — without it, a rebuild resets everything the extension wrote.
|
|
79
|
+
// Live delivery stays with the extension's own MSP: implicit projections are
|
|
80
|
+
// skipped by the inline runner, so the apply here runs ONLY during rebuild.
|
|
81
|
+
export type EntityProjectionExtension = {
|
|
82
|
+
// Aggregate-types beyond the entity's own stream whose events the rebuild
|
|
83
|
+
// must scan (e.g. "field-definition"). Omit when all extension events are
|
|
84
|
+
// appended on the host entity's stream.
|
|
85
|
+
readonly sources?: readonly string[];
|
|
86
|
+
// Keyed by fully-qualified event type. Must not collide with the entity's
|
|
87
|
+
// built-in lifecycle applies (<entity>.created/updated/...) or another
|
|
88
|
+
// extension — collisions fail at boot.
|
|
89
|
+
readonly apply: Readonly<Record<string, SingleStreamApplyFn>>;
|
|
90
|
+
};
|
|
91
|
+
|
|
64
92
|
// Per-lifecycle error policy for a MultiStreamProjection. Mirrors Marten's
|
|
65
93
|
// Projections.Errors / Projections.RebuildErrors split — a projection can
|
|
66
94
|
// be lenient during steady-state delivery but strict during rebuild (or
|
package/src/event-store/index.ts
CHANGED
|
@@ -24,6 +24,14 @@ export {
|
|
|
24
24
|
streamAllEventsByType,
|
|
25
25
|
} from "./event-store";
|
|
26
26
|
export { createEventsTable, eventsTable } from "./events-schema";
|
|
27
|
+
export {
|
|
28
|
+
createRebuildDeadLetterTable,
|
|
29
|
+
listRebuildDeadLetters,
|
|
30
|
+
type RebuildDeadLetterRow,
|
|
31
|
+
rebuildDeadLetterTable,
|
|
32
|
+
recordRebuildDeadLetters,
|
|
33
|
+
type SkippedApply,
|
|
34
|
+
} from "./rebuild-dead-letter";
|
|
27
35
|
export { toStoredEvent } from "./row-to-stored-event";
|
|
28
36
|
export {
|
|
29
37
|
createSnapshotsTable,
|
|
@@ -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
|
+
}
|
|
@@ -224,4 +224,12 @@ describe("file download roundtrip (GET /files/:id)", () => {
|
|
|
224
224
|
await deleteFile(admin, id);
|
|
225
225
|
expect((await download(admin, id)).status).toBe(404);
|
|
226
226
|
});
|
|
227
|
+
|
|
228
|
+
test("a cross-tenant download is rejected (404, no existence oracle)", async () => {
|
|
229
|
+
const id = await upload(admin, "tenant-a-only.png", SMALL);
|
|
230
|
+
const res = await download(otherAdmin, id);
|
|
231
|
+
expect(res.status).toBe(404);
|
|
232
|
+
const body = (await res.json()) as { error?: string };
|
|
233
|
+
expect(body.error).toBe("not_found");
|
|
234
|
+
});
|
|
227
235
|
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -150,8 +150,8 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
150
150
|
// are orphaned in the provider; cleanup-jobs sweep those later. Losing a
|
|
151
151
|
// row on append-failure is acceptable; corrupting a committed row with a
|
|
152
152
|
// missing binary is not.
|
|
153
|
-
const data = new Uint8Array(await file.arrayBuffer());
|
|
154
153
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
154
|
+
const data = new Uint8Array(await file.arrayBuffer());
|
|
155
155
|
await storageProvider.write(storageKey, data, file.type);
|
|
156
156
|
|
|
157
157
|
// Create via the standard entity executor: emits fileRef.created +
|
|
@@ -547,6 +547,49 @@ describe("write-handler shape guard", () => {
|
|
|
547
547
|
});
|
|
548
548
|
});
|
|
549
549
|
|
|
550
|
+
// --- Dispatch: geoTzProvider seam ---
|
|
551
|
+
|
|
552
|
+
describe("dispatcher context.geoTzProvider (680/1)", () => {
|
|
553
|
+
test("ctx.tz.fromCoordinates reaches the app-injected geoTzProvider through a real dispatch", async () => {
|
|
554
|
+
// All geo-tz.test.ts coverage calls createTzContext({ geoTz }) directly —
|
|
555
|
+
// none go through the dispatcher or read context.geoTzProvider. A typo in
|
|
556
|
+
// the property forwarding at dispatcher.ts would make the injection a
|
|
557
|
+
// silent no-op (provider configured, ctx.tz.fromCoordinates still throws).
|
|
558
|
+
const geoFeature = defineFeature("geo", (r) => {
|
|
559
|
+
r.queryHandler(
|
|
560
|
+
"resolve-zone",
|
|
561
|
+
z.object({ latitude: z.number(), longitude: z.number() }),
|
|
562
|
+
async (query, ctx) => ({
|
|
563
|
+
zone: await ctx.tz.fromCoordinates({
|
|
564
|
+
latitude: query.payload.latitude,
|
|
565
|
+
longitude: query.payload.longitude,
|
|
566
|
+
}),
|
|
567
|
+
}),
|
|
568
|
+
{ access: { openToAll: true } },
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
const registry = createRegistry([geoFeature]);
|
|
572
|
+
const calls: Array<{ latitude: number; longitude: number }> = [];
|
|
573
|
+
const dispatcher = createDispatcher(registry, {
|
|
574
|
+
geoTzProvider: {
|
|
575
|
+
fromCoordinates: ({ latitude, longitude }) => {
|
|
576
|
+
calls.push({ latitude, longitude });
|
|
577
|
+
return "Europe/Berlin";
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const result = await dispatcher.query(
|
|
583
|
+
"geo:query:resolve-zone",
|
|
584
|
+
{ latitude: 52.52, longitude: 13.405 },
|
|
585
|
+
createTestUser(),
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
expect(result).toEqual({ zone: "Europe/Berlin" });
|
|
589
|
+
expect(calls).toEqual([{ latitude: 52.52, longitude: 13.405 }]);
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
550
593
|
// --- Mock helpers ---
|
|
551
594
|
|
|
552
595
|
function createMockIdempotencyGuard() {
|
|
@@ -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",
|
|
@@ -750,6 +750,30 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
|
|
|
750
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
|
+
test("(656/1) a clean rebuild with no concurrency does not trigger the #443 recompute", async () => {
|
|
754
|
+
// The #443 shortfall-recompute rebuilds the shadow a 2nd time and re-replays
|
|
755
|
+
// the whole log — correct on the rare path it guards, but a silent perf
|
|
756
|
+
// regression if it fired on every rebuild. Its final values are identical
|
|
757
|
+
// either way (full re-replay is idempotent), so only a call-count seam can
|
|
758
|
+
// prove it stayed off the happy path.
|
|
759
|
+
const group = "00000000-0000-4000-8000-000000000203";
|
|
760
|
+
await appendCreatedEvent(group, "a");
|
|
761
|
+
await appendCreatedEvent(group, "b");
|
|
762
|
+
|
|
763
|
+
let shadowBuilds = 0;
|
|
764
|
+
const result = await rebuildProjection(qualifiedProjectionName, {
|
|
765
|
+
db: testDb.db,
|
|
766
|
+
registry,
|
|
767
|
+
__test_onBuildShadowTable: () => {
|
|
768
|
+
shadowBuilds++;
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(shadowBuilds).toBe(1);
|
|
773
|
+
expect(result.eventsProcessed).toBe(2);
|
|
774
|
+
expect(await getCount(group)).toBe(2);
|
|
775
|
+
});
|
|
776
|
+
|
|
753
777
|
test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
|
|
754
778
|
// The catch-up loop only works if each fresh SELECT in the rebuild tx sees
|
|
755
779
|
// rows other connections committed since the previous batch. Under
|
|
@@ -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
|
+
});
|