@cosmicdrift/kumiko-framework 0.37.0 → 0.39.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/api/__tests__/auth-routes-cookie.test.ts +79 -0
- package/src/api/auth-routes.ts +36 -8
- package/src/api/routes.ts +2 -7
- package/src/api/server.ts +1 -1
- package/src/bun-db/connection.ts +1 -0
- package/src/bun-db/index.ts +0 -1
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +42 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +34 -0
- package/src/db/collect-table-metas.ts +2 -2
- package/src/db/connection.ts +1 -0
- package/src/db/dialect.ts +16 -3
- package/src/db/event-store-executor.ts +29 -0
- package/src/db/index.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/tenant-db.ts +14 -4
- package/src/engine/__tests__/build-app-schema.test.ts +31 -3
- package/src/engine/__tests__/engine.test.ts +3 -3
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +8 -8
- package/src/engine/__tests__/post-query-hook.test.ts +3 -3
- package/src/engine/__tests__/validation-hooks.test.ts +2 -2
- package/src/engine/boot-validator/entity-handler.ts +14 -11
- package/src/engine/boot-validator/ownership.ts +1 -1
- package/src/engine/boot-validator/pii-retention.ts +1 -1
- package/src/engine/boot-validator/screens-nav.ts +9 -6
- package/src/engine/build-app-schema.ts +42 -12
- package/src/engine/create-app.ts +1 -1
- package/src/engine/define-feature.ts +5 -1
- package/src/engine/feature-ast/extractors/round4.ts +1 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/registry.ts +4 -3
- package/src/engine/steps/unsafe-projection-upsert.ts +4 -15
- package/src/engine/types/feature.ts +7 -3
- package/src/engine/types/hooks.ts +15 -11
- package/src/engine/types/screen.ts +2 -7
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/engine/validation.ts +1 -1
- package/src/errors/index.ts +1 -1
- package/src/errors/to-kumiko-error.ts +8 -0
- package/src/event-store/archive.ts +1 -0
- package/src/event-store/event-store.ts +1 -16
- package/src/event-store/index.ts +1 -0
- package/src/event-store/row-to-stored-event.ts +34 -0
- package/src/logging/__tests__/fallback-logger.test.ts +5 -5
- package/src/logging/utils.ts +1 -1
- package/src/migrations/projection-table-index.ts +3 -15
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +75 -0
- package/src/pipeline/dispatcher-utils.ts +2 -12
- package/src/pipeline/event-consumer-state.ts +1 -0
- package/src/pipeline/event-dispatcher.ts +21 -42
- package/src/pipeline/msp-rebuild.ts +8 -19
- package/src/pipeline/projection-rebuild.ts +2 -13
- package/src/pipeline/system-hooks.ts +17 -4
- package/src/random/words.ts +4 -3
- package/src/stack/test-stack.ts +1 -1
|
@@ -14,6 +14,7 @@ import { stringifyJson } from "../utils/safe-json";
|
|
|
14
14
|
import { isStreamArchived } from "./archive";
|
|
15
15
|
import { VersionConflictError } from "./errors";
|
|
16
16
|
import { eventsTable } from "./events-schema";
|
|
17
|
+
import { toStoredEvent } from "./row-to-stored-event";
|
|
17
18
|
|
|
18
19
|
export type EventMetadata = {
|
|
19
20
|
readonly userId: string;
|
|
@@ -409,19 +410,3 @@ export async function* streamAllEventsByType(
|
|
|
409
410
|
cursorId = nextCursor;
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
|
-
|
|
413
|
-
function toStoredEvent(row: SelectedEvent): StoredEvent {
|
|
414
|
-
return {
|
|
415
|
-
id: String(row.id),
|
|
416
|
-
aggregateId: row.aggregateId,
|
|
417
|
-
aggregateType: row.aggregateType,
|
|
418
|
-
tenantId: row.tenantId,
|
|
419
|
-
version: row.version,
|
|
420
|
-
type: row.type,
|
|
421
|
-
eventVersion: row.eventVersion,
|
|
422
|
-
payload: row.payload,
|
|
423
|
-
metadata: row.metadata,
|
|
424
|
-
createdAt: row.createdAt,
|
|
425
|
-
createdBy: row.createdBy,
|
|
426
|
-
};
|
|
427
|
-
}
|
package/src/event-store/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export {
|
|
|
24
24
|
streamAllEventsByType,
|
|
25
25
|
} from "./event-store";
|
|
26
26
|
export { createEventsTable, eventsTable } from "./events-schema";
|
|
27
|
+
export { toStoredEvent } from "./row-to-stored-event";
|
|
27
28
|
export {
|
|
28
29
|
createSnapshotsTable,
|
|
29
30
|
type LoadAggregateWithSnapshotResult,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types";
|
|
2
|
+
import type { EventMetadata, StoredEvent } from "./event-store";
|
|
3
|
+
|
|
4
|
+
// Minimal row shape accepted by toStoredEvent. Both SelectedEvent
|
|
5
|
+
// (event-store) and StoredEventRow (event-dispatcher) satisfy it.
|
|
6
|
+
type EventRow = {
|
|
7
|
+
readonly id: bigint;
|
|
8
|
+
readonly aggregateId: string;
|
|
9
|
+
readonly aggregateType: string;
|
|
10
|
+
readonly tenantId: TenantId;
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly eventVersion: number;
|
|
14
|
+
readonly payload: Record<string, unknown>;
|
|
15
|
+
readonly metadata: EventMetadata;
|
|
16
|
+
readonly createdAt: Temporal.Instant;
|
|
17
|
+
readonly createdBy: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function toStoredEvent(row: EventRow): StoredEvent {
|
|
21
|
+
return {
|
|
22
|
+
id: String(row.id),
|
|
23
|
+
aggregateId: row.aggregateId,
|
|
24
|
+
aggregateType: row.aggregateType,
|
|
25
|
+
tenantId: row.tenantId,
|
|
26
|
+
version: row.version,
|
|
27
|
+
type: row.type,
|
|
28
|
+
eventVersion: row.eventVersion,
|
|
29
|
+
payload: row.payload,
|
|
30
|
+
metadata: row.metadata,
|
|
31
|
+
createdAt: row.createdAt,
|
|
32
|
+
createdBy: row.createdBy,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// createFallbackLogger Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
2
|
//
|
|
3
|
-
// Pinnt beide Pfade des Fallback-Loggers
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Pinnt beide Pfade des Fallback-Loggers auf das identische
|
|
4
|
+
// "[ns] msg"-Format — wrapped logger und console-Fallback dürfen
|
|
5
|
+
// nicht divergieren (Log-Parser/Grep-Konsistenz).
|
|
6
6
|
|
|
7
7
|
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
8
8
|
import { createFallbackLogger } from "../utils";
|
|
@@ -30,7 +30,7 @@ describe("createFallbackLogger", () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe("ohne logger (console-Fallback)", () => {
|
|
33
|
-
test("schreibt auf console.error mit [namespace]-Prefix
|
|
33
|
+
test("schreibt auf console.error mit [namespace]-Prefix im selben Format wie wrapped", () => {
|
|
34
34
|
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
35
35
|
try {
|
|
36
36
|
const fallback = createFallbackLogger("boot");
|
|
@@ -38,7 +38,7 @@ describe("createFallbackLogger", () => {
|
|
|
38
38
|
fallback.error("no logger wired", { phase: "init" });
|
|
39
39
|
|
|
40
40
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
41
|
-
expect(spy).toHaveBeenCalledWith("[boot] no logger wired
|
|
41
|
+
expect(spy).toHaveBeenCalledWith("[boot] no logger wired", { phase: "init" });
|
|
42
42
|
} finally {
|
|
43
43
|
spy.mockRestore();
|
|
44
44
|
}
|
package/src/logging/utils.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function createFallbackLogger(
|
|
|
18
18
|
return {
|
|
19
19
|
error(msg, data) {
|
|
20
20
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
21
|
-
console.error(`[${namespace}] ${msg}
|
|
21
|
+
console.error(`[${namespace}] ${msg}`, data);
|
|
22
22
|
},
|
|
23
23
|
};
|
|
24
24
|
}
|
|
@@ -5,31 +5,19 @@
|
|
|
5
5
|
// Drizzle-frei: der Tabellen-Name kommt aus dem kumiko-Symbol das
|
|
6
6
|
// buildEntityTable/buildEntityTableMeta an die Table-Definition hängt.
|
|
7
7
|
|
|
8
|
+
import { extractTableName } from "../db";
|
|
8
9
|
import type { Registry } from "../engine/types/feature";
|
|
9
10
|
|
|
10
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
11
|
-
|
|
12
|
-
function getTableName(table: unknown): string {
|
|
13
|
-
if (typeof table !== "object" || table === null) {
|
|
14
|
-
throw new Error("projection-table-index: table is not a table object");
|
|
15
|
-
}
|
|
16
|
-
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
17
|
-
if (typeof name !== "string") {
|
|
18
|
-
throw new Error("projection-table-index: table missing kumiko name symbol");
|
|
19
|
-
}
|
|
20
|
-
return name;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
11
|
/** Index `tableName → projection-name` aus der Registry. Nur Projections mit
|
|
24
12
|
* table-Definition (single-stream + multi-stream-with-table) zählen.
|
|
25
13
|
* Side-effect-only MSPs (table omitted) haben keinen Rebuild-Sinn. */
|
|
26
14
|
export function buildProjectionTableIndex(registry: Registry): ReadonlyMap<string, string> {
|
|
27
15
|
const index = new Map<string, string>();
|
|
28
16
|
for (const [name, def] of registry.getAllProjections()) {
|
|
29
|
-
index.set(
|
|
17
|
+
index.set(extractTableName(def.table, `projection-table-index(${name})`), name);
|
|
30
18
|
}
|
|
31
19
|
for (const [name, def] of registry.getAllMultiStreamProjections()) {
|
|
32
|
-
if (def.table) index.set(
|
|
20
|
+
if (def.table) index.set(extractTableName(def.table, `projection-table-index(${name})`), name);
|
|
33
21
|
}
|
|
34
22
|
return index;
|
|
35
23
|
}
|
|
@@ -83,6 +83,26 @@ const archFeature = defineFeature("archtest", (r) => {
|
|
|
83
83
|
{ access: { roles: ["Admin"] } },
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
r.writeHandler(
|
|
87
|
+
"item:update",
|
|
88
|
+
z.object({ id: z.uuid(), label: z.string() }),
|
|
89
|
+
async (event, ctx) =>
|
|
90
|
+
executor.update(
|
|
91
|
+
{ id: event.payload.id, changes: { label: event.payload.label } },
|
|
92
|
+
event.user,
|
|
93
|
+
ctx.db,
|
|
94
|
+
{ skipOptimisticLock: true },
|
|
95
|
+
),
|
|
96
|
+
{ access: { roles: ["Admin"] } },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
r.writeHandler(
|
|
100
|
+
"item:delete",
|
|
101
|
+
z.object({ id: z.uuid() }),
|
|
102
|
+
async (event, ctx) => executor.delete({ id: event.payload.id }, event.user, ctx.db),
|
|
103
|
+
{ access: { roles: ["Admin"] } },
|
|
104
|
+
);
|
|
105
|
+
|
|
86
106
|
r.queryHandler(
|
|
87
107
|
"item:events",
|
|
88
108
|
z.object({ id: z.uuid() }),
|
|
@@ -217,4 +237,59 @@ describe("archiveStream — Marten ArchiveStream equivalent", () => {
|
|
|
217
237
|
expect(err.tenantId).toBe(admin.tenantId);
|
|
218
238
|
expect(err.name).toBe("ArchivedStreamError");
|
|
219
239
|
});
|
|
240
|
+
|
|
241
|
+
// The CRUD executor appends via append() + getStreamVersion(), neither of
|
|
242
|
+
// which consults the archive flag. Without the executor-level guard a
|
|
243
|
+
// PATCH/DELETE would silently land an event on an archived stream — the
|
|
244
|
+
// read-only contract honoured by ctx.appendEvent would not extend to
|
|
245
|
+
// entity-CRUD writes. These prove the guard closes that gap on both paths.
|
|
246
|
+
describe("CRUD writes honour the archive guard", () => {
|
|
247
|
+
test("executor.update on an archived stream is rejected, no event lands", async () => {
|
|
248
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
249
|
+
"archtest:write:item:create",
|
|
250
|
+
{ label: "before-archive" },
|
|
251
|
+
admin,
|
|
252
|
+
);
|
|
253
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
254
|
+
|
|
255
|
+
const res = await stack.http.write(
|
|
256
|
+
"archtest:write:item:update",
|
|
257
|
+
{ id, label: "too-late" },
|
|
258
|
+
admin,
|
|
259
|
+
);
|
|
260
|
+
expect(res.status).toBe(500);
|
|
261
|
+
|
|
262
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, { includeArchived: true });
|
|
263
|
+
expect(raw.map((e) => e.type)).not.toContain("arch-item.updated");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("executor.delete on an archived stream is rejected, no event lands", async () => {
|
|
267
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
268
|
+
"archtest:write:item:create",
|
|
269
|
+
{ label: "keep-me" },
|
|
270
|
+
admin,
|
|
271
|
+
);
|
|
272
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
273
|
+
|
|
274
|
+
const res = await stack.http.write("archtest:write:item:delete", { id }, admin);
|
|
275
|
+
expect(res.status).toBe(500);
|
|
276
|
+
|
|
277
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, { includeArchived: true });
|
|
278
|
+
expect(raw.map((e) => e.type)).not.toContain("arch-item.deleted");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("restoreStream re-opens the stream for CRUD updates", async () => {
|
|
282
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
283
|
+
"archtest:write:item:create",
|
|
284
|
+
{ label: "v1" },
|
|
285
|
+
admin,
|
|
286
|
+
);
|
|
287
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
288
|
+
await stack.http.writeOk("archtest:write:item:restore", { id }, admin);
|
|
289
|
+
|
|
290
|
+
await stack.http.writeOk("archtest:write:item:update", { id, label: "v2" }, admin);
|
|
291
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId);
|
|
292
|
+
expect(raw.map((e) => e.type)).toEqual(["arch-item.created", "arch-item.updated"]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
220
295
|
});
|
|
@@ -6,13 +6,7 @@ import type {
|
|
|
6
6
|
SessionUser,
|
|
7
7
|
WriteResult,
|
|
8
8
|
} from "../engine/types";
|
|
9
|
-
import {
|
|
10
|
-
type FieldIssue,
|
|
11
|
-
InternalError,
|
|
12
|
-
isKumikoError,
|
|
13
|
-
type KumikoError,
|
|
14
|
-
type WriteErrorInfo,
|
|
15
|
-
} from "../errors";
|
|
9
|
+
import { type FieldIssue, toKumikoError, type WriteErrorInfo } from "../errors";
|
|
16
10
|
|
|
17
11
|
export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
|
|
18
12
|
|
|
@@ -182,8 +176,4 @@ export function resolveType(type: HandlerType): string {
|
|
|
182
176
|
return typeof type === "string" ? type : type.name;
|
|
183
177
|
}
|
|
184
178
|
|
|
185
|
-
export
|
|
186
|
-
if (isKumikoError(e)) return e;
|
|
187
|
-
if (e instanceof Error) return new InternalError({ cause: e });
|
|
188
|
-
return new InternalError({ message: String(e) });
|
|
189
|
-
}
|
|
179
|
+
export const wrapToKumiko = toKumikoError;
|
|
@@ -97,6 +97,7 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
|
|
|
97
97
|
// table is already present (second stack in the same test DB, prod boot
|
|
98
98
|
// after migration), skip cleanly.
|
|
99
99
|
//
|
|
100
|
+
// guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
|
|
100
101
|
export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
|
|
101
102
|
// skip: table already exists — bootstrap is called from multiple paths
|
|
102
103
|
if (await tableExists(db, "public.kumiko_event_consumers")) return;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
EVENTS_PUBSUB_CHANNEL,
|
|
17
17
|
eventsTable,
|
|
18
18
|
getEventsHighWaterMark,
|
|
19
|
+
toStoredEvent as rowToStoredEvent,
|
|
19
20
|
type StoredEvent,
|
|
20
21
|
} from "../event-store";
|
|
21
22
|
import {
|
|
@@ -298,22 +299,6 @@ type DeliveryOutcome = {
|
|
|
298
299
|
readonly failed: number;
|
|
299
300
|
};
|
|
300
301
|
|
|
301
|
-
function rowToStoredEvent(row: StoredEventRow): StoredEvent {
|
|
302
|
-
return {
|
|
303
|
-
id: String(row.id),
|
|
304
|
-
aggregateId: row.aggregateId,
|
|
305
|
-
aggregateType: row.aggregateType,
|
|
306
|
-
tenantId: row.tenantId,
|
|
307
|
-
version: row.version,
|
|
308
|
-
type: row.type,
|
|
309
|
-
eventVersion: row.eventVersion,
|
|
310
|
-
payload: row.payload,
|
|
311
|
-
metadata: row.metadata,
|
|
312
|
-
createdAt: row.createdAt,
|
|
313
|
-
createdBy: row.createdBy,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
302
|
// Deliver events to the consumer's handler in events.id order. Halt-on-
|
|
318
303
|
// poison: a throw breaks the loop, the cursor stays at the last successful
|
|
319
304
|
// event, and attempts climb. At maxAttempts the caller persists status=
|
|
@@ -743,18 +728,13 @@ async function requireConsumerRow(
|
|
|
743
728
|
return row;
|
|
744
729
|
}
|
|
745
730
|
|
|
746
|
-
|
|
731
|
+
async function applyConsumerStatusTransition(
|
|
747
732
|
db: DbConnection,
|
|
748
733
|
name: string,
|
|
749
|
-
instanceId: string
|
|
734
|
+
instanceId: string,
|
|
735
|
+
targetStatus: "idle" | "disabled",
|
|
750
736
|
): Promise<ConsumerRecoveryState> {
|
|
751
|
-
const
|
|
752
|
-
if (before.status !== "dead") {
|
|
753
|
-
throw new Error(
|
|
754
|
-
`Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
|
|
755
|
-
);
|
|
756
|
-
}
|
|
757
|
-
const raw = await updateConsumerStatusReturning(db, name, instanceId, "idle");
|
|
737
|
+
const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
|
|
758
738
|
const updated =
|
|
759
739
|
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
760
740
|
if (!updated) {
|
|
@@ -765,21 +745,27 @@ export async function restartConsumer(
|
|
|
765
745
|
return normalizeConsumerState(updated);
|
|
766
746
|
}
|
|
767
747
|
|
|
768
|
-
export async function
|
|
748
|
+
export async function restartConsumer(
|
|
769
749
|
db: DbConnection,
|
|
770
750
|
name: string,
|
|
771
751
|
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
772
752
|
): Promise<ConsumerRecoveryState> {
|
|
773
|
-
await requireConsumerRow(db, name, instanceId);
|
|
774
|
-
|
|
775
|
-
const updated =
|
|
776
|
-
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
777
|
-
if (!updated) {
|
|
753
|
+
const before = await requireConsumerRow(db, name, instanceId);
|
|
754
|
+
if (before.status !== "dead") {
|
|
778
755
|
throw new Error(
|
|
779
|
-
`Consumer "${name}" (instance_id="${instanceId}")
|
|
756
|
+
`Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
|
|
780
757
|
);
|
|
781
758
|
}
|
|
782
|
-
return
|
|
759
|
+
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export async function disableConsumer(
|
|
763
|
+
db: DbConnection,
|
|
764
|
+
name: string,
|
|
765
|
+
instanceId: string = SHARED_INSTANCE_SENTINEL,
|
|
766
|
+
): Promise<ConsumerRecoveryState> {
|
|
767
|
+
await requireConsumerRow(db, name, instanceId);
|
|
768
|
+
return applyConsumerStatusTransition(db, name, instanceId, "disabled");
|
|
783
769
|
}
|
|
784
770
|
|
|
785
771
|
export async function enableConsumer(
|
|
@@ -793,15 +779,7 @@ export async function enableConsumer(
|
|
|
793
779
|
`Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
|
|
794
780
|
);
|
|
795
781
|
}
|
|
796
|
-
|
|
797
|
-
const updated =
|
|
798
|
-
raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
|
|
799
|
-
if (!updated) {
|
|
800
|
-
throw new Error(
|
|
801
|
-
`Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
|
|
802
|
-
);
|
|
803
|
-
}
|
|
804
|
-
return normalizeConsumerState(updated);
|
|
782
|
+
return applyConsumerStatusTransition(db, name, instanceId, "idle");
|
|
805
783
|
}
|
|
806
784
|
|
|
807
785
|
// skipPoisonEvent advances the cursor past the first event after the
|
|
@@ -948,6 +926,7 @@ export type ConsumerProgress = {
|
|
|
948
926
|
// post-commit — lag is the primary signal for backpressure, dead consumers,
|
|
949
927
|
// or dispatcher stalls. Programmatic callers can map the result to a
|
|
950
928
|
// `kumiko_consumer_lag{name}` Prometheus gauge.
|
|
929
|
+
// guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
|
|
951
930
|
export async function getAllConsumerProgress(
|
|
952
931
|
db: DbConnection,
|
|
953
932
|
registeredNames: readonly string[],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractTableName } from "../db";
|
|
1
2
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
2
3
|
import {
|
|
3
4
|
markConsumerRebuildFailed,
|
|
@@ -91,16 +92,16 @@ export async function rebuildMultiStreamProjection(
|
|
|
91
92
|
const { db, registry } = deps;
|
|
92
93
|
const msp = registry.getAllMultiStreamProjections().get(mspName);
|
|
93
94
|
if (!msp) {
|
|
94
|
-
throw new
|
|
95
|
-
`MultiStreamProjection "${mspName}" is not registered. Known: ${
|
|
95
|
+
throw new InternalError({
|
|
96
|
+
message: `MultiStreamProjection "${mspName}" is not registered. Known: ${
|
|
96
97
|
[...registry.getAllMultiStreamProjections().keys()].join(", ") || "(none)"
|
|
97
98
|
}`,
|
|
98
|
-
);
|
|
99
|
+
});
|
|
99
100
|
}
|
|
100
101
|
if (!msp.table) {
|
|
101
|
-
throw new
|
|
102
|
-
`MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use bun kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
|
|
103
|
-
);
|
|
102
|
+
throw new InternalError({
|
|
103
|
+
message: `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use bun kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
|
|
104
|
+
});
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
const startedAt = Date.now();
|
|
@@ -113,7 +114,7 @@ export async function rebuildMultiStreamProjection(
|
|
|
113
114
|
await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
|
|
114
115
|
|
|
115
116
|
const mspTable = msp.table as NonNullable<typeof msp.table>;
|
|
116
|
-
const tableName =
|
|
117
|
+
const tableName = extractTableName(mspTable, "msp-rebuild");
|
|
117
118
|
await truncateTable(tx, tableName);
|
|
118
119
|
|
|
119
120
|
const subscribedTypes = Object.keys(msp.apply);
|
|
@@ -204,15 +205,3 @@ export async function rebuildMultiStreamProjection(
|
|
|
204
205
|
deps.onMetrics?.(result);
|
|
205
206
|
return result;
|
|
206
207
|
}
|
|
207
|
-
|
|
208
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
209
|
-
function getTableName(table: unknown): string {
|
|
210
|
-
if (typeof table !== "object" || table === null) {
|
|
211
|
-
throw new InternalError({ message: "msp-rebuild: msp.table is not a pgTable object" });
|
|
212
|
-
}
|
|
213
|
-
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
214
|
-
if (typeof name !== "string") {
|
|
215
|
-
throw new InternalError({ message: "msp-rebuild: msp.table missing drizzle name symbol" });
|
|
216
|
-
}
|
|
217
|
-
return name;
|
|
218
|
-
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extractTableName } from "../db";
|
|
1
2
|
import type { DbConnection, DbTx } from "../db/connection";
|
|
2
3
|
import {
|
|
3
4
|
finalizeProjectionRebuild,
|
|
@@ -96,7 +97,7 @@ export async function rebuildProjection(
|
|
|
96
97
|
await db.begin(async (tx: DbTx) => {
|
|
97
98
|
await markProjectionRebuilding(tx, projectionName);
|
|
98
99
|
|
|
99
|
-
const tableName =
|
|
100
|
+
const tableName = extractTableName(projection.table, "projection-rebuild");
|
|
100
101
|
await truncateTable(tx, tableName);
|
|
101
102
|
|
|
102
103
|
// Stream events in chronological order for every source. The event
|
|
@@ -199,18 +200,6 @@ export async function rebuildProjection(
|
|
|
199
200
|
return result;
|
|
200
201
|
}
|
|
201
202
|
|
|
202
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
203
|
-
function getTableName(table: unknown): string {
|
|
204
|
-
if (typeof table !== "object" || table === null) {
|
|
205
|
-
throw new Error("projection-rebuild: projection.table is not a pgTable object");
|
|
206
|
-
}
|
|
207
|
-
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
208
|
-
if (typeof name !== "string") {
|
|
209
|
-
throw new Error("projection-rebuild: projection.table missing drizzle name symbol");
|
|
210
|
-
}
|
|
211
|
-
return name;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
203
|
// Read-only status for one projection. Returns null if the projection was
|
|
215
204
|
// registered but never rebuilt (no row yet).
|
|
216
205
|
export async function getProjectionState(
|
|
@@ -95,6 +95,21 @@ function reconstructStateForSearch(
|
|
|
95
95
|
return (payload["previous"] as Record<string, unknown> | undefined) ?? {}; // @cast-boundary engine-payload
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// buildSearchDocument runs per save — without dedup a colliding contributor
|
|
99
|
+
// key would spam one warn line per write on the hotpath.
|
|
100
|
+
const warnedKeyCollisions = new Set<string>();
|
|
101
|
+
function warnOncePerKeyCollision(entityName: string, key: string, isBaseField: boolean): void {
|
|
102
|
+
const dedupKey = `${entityName}:${key}`;
|
|
103
|
+
if (!warnedKeyCollisions.has(dedupKey)) {
|
|
104
|
+
warnedKeyCollisions.add(dedupKey);
|
|
105
|
+
const collidesWith = isBaseField ? `Stammfield "${key}"` : `earlier contributor key "${key}"`;
|
|
106
|
+
console.warn(
|
|
107
|
+
`[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
|
|
108
|
+
`${collidesWith} — keeping the first value. Rename the contributor key.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
// Build a SearchDocument from raw field-state. Parallel to the old
|
|
99
114
|
// buildSearchDocument that took a SaveContext — same selector logic, just
|
|
100
115
|
// a different input shape.
|
|
@@ -149,14 +164,12 @@ export async function buildSearchDocument(
|
|
|
149
164
|
// searchable Stammfield is dropped (not silently merged over the real value)
|
|
150
165
|
// and warned. A jsonb custom-field that happens to share a Stammfield name
|
|
151
166
|
// must not shadow the indexed Stammfield.
|
|
167
|
+
const baseFieldKeys = new Set(Object.keys(fields));
|
|
152
168
|
for (const contribute of extensions) {
|
|
153
169
|
const contributed = await contribute({ entityName, entityId, state });
|
|
154
170
|
for (const [key, value] of Object.entries(contributed)) {
|
|
155
171
|
if (Object.hasOwn(fields, key)) {
|
|
156
|
-
|
|
157
|
-
`[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
|
|
158
|
-
`Stammfield "${key}" — keeping the base field. Rename the contributor key.`,
|
|
159
|
-
);
|
|
172
|
+
warnOncePerKeyCollision(entityName, key, baseFieldKeys.has(key));
|
|
160
173
|
continue;
|
|
161
174
|
}
|
|
162
175
|
fields[key] = value;
|
package/src/random/words.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
// - Aussprechbar in Deutsch UND Englisch (User-Telefon-Support)
|
|
14
14
|
// - Keine Wörter mit ambiguer Bedeutung in Englisch+Deutsch
|
|
15
15
|
//
|
|
16
|
-
// 191 × 173 = 33.043 saubere Kombinationen
|
|
17
|
-
//
|
|
18
|
-
//
|
|
16
|
+
// 191 × 173 = 33.043 saubere Kombinationen. Birthday-Bound: ab ~180
|
|
17
|
+
// Tenants wird kumulativ die erste Kollision wahrscheinlich (~50%);
|
|
18
|
+
// pro Einzel-Draw bleibt p(Kollision) < 1% (vgl. generate.ts). Bei
|
|
19
|
+
// Kollision greift der Suffix-Pfad in generateUniqueName.
|
|
19
20
|
//
|
|
20
21
|
// Erweiterung: weitere Wörter unten anhängen reicht (sortiert ist
|
|
21
22
|
// hilfreich für Reviews aber nicht erforderlich). Caller können auch
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -212,7 +212,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
212
212
|
if (enabledHooks.includes("search")) {
|
|
213
213
|
const searchableFields: string[] = [];
|
|
214
214
|
for (const feature of options.features) {
|
|
215
|
-
for (const [, entity] of Object.entries(feature.entities)) {
|
|
215
|
+
for (const [, entity] of Object.entries(feature.entities ?? {})) {
|
|
216
216
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
217
217
|
if (field.type === "text" && field.searchable) {
|
|
218
218
|
searchableFields.push(fieldName);
|