@cosmicdrift/kumiko-framework 0.36.0 → 0.38.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/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
|
@@ -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);
|