@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -19,86 +19,25 @@
|
|
|
19
19
|
// append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
|
|
20
20
|
|
|
21
21
|
import { collectPiiSubjectFields } from "../crypto";
|
|
22
|
+
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
22
23
|
import type {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
BuildEntityTableMetaOptions,
|
|
25
|
+
ColumnMeta,
|
|
26
|
+
EntityTableMeta,
|
|
27
|
+
IndexMeta,
|
|
28
|
+
UnmanagedTableInput,
|
|
29
|
+
} from "./entity-table-meta-types";
|
|
28
30
|
import { READ_MODEL_PREFIX, toSnakeCase, toTableName } from "./table-builder";
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
| "double precision"
|
|
40
|
-
| "bigint"
|
|
41
|
-
| "serial"
|
|
42
|
-
| "bigserial"
|
|
43
|
-
| "jsonb"
|
|
44
|
-
| "timestamptz"
|
|
45
|
-
| "timestamptz(3)"
|
|
46
|
-
// Exact decimal — precision/scale are encoded in the type string so the
|
|
47
|
-
// DDL renderer and read-coercion need no side-channel metadata.
|
|
48
|
-
| `numeric(${number},${number})`;
|
|
49
|
-
|
|
50
|
-
export type ColumnMeta = {
|
|
51
|
-
readonly name: string; // snake_case PG column name
|
|
52
|
-
readonly pgType: PgType;
|
|
53
|
-
readonly notNull: boolean;
|
|
54
|
-
// Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
|
|
55
|
-
// `'[]'::jsonb`). undefined = no DEFAULT clause.
|
|
56
|
-
readonly defaultSql?: string;
|
|
57
|
-
readonly primaryKey?: boolean;
|
|
58
|
-
readonly identity?: boolean;
|
|
59
|
-
// bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
|
|
60
|
-
// drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
|
|
61
|
-
readonly bigintJsMode?: "number" | "bigint";
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export type IndexMeta = {
|
|
65
|
-
readonly name: string;
|
|
66
|
-
readonly columns: readonly string[]; // snake_case PG column names
|
|
67
|
-
readonly unique?: boolean;
|
|
68
|
-
// Raw SQL-where-expression for partial indexes. Caller is responsible
|
|
69
|
-
// for safety — emitted verbatim.
|
|
70
|
-
readonly whereSql?: string;
|
|
71
|
-
// Set wenn die EntityDefinition ein partial-Index hat (def.where als
|
|
72
|
-
// drizzle SQL-AST), der vom Generator nicht zuverlässig renderbar ist.
|
|
73
|
-
// Renderer emittiert das Statement dann AUSKOMMENTIERT mit Warn-Hint —
|
|
74
|
-
// App-Author muss das WHERE manuell im generierten SQL hinzufügen.
|
|
75
|
-
readonly needsManualWhere?: boolean;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export type CompositePrimaryKeyMeta = {
|
|
79
|
-
readonly name: string;
|
|
80
|
-
readonly columns: readonly string[];
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export type EntityTableMeta = {
|
|
84
|
-
readonly tableName: string;
|
|
85
|
-
readonly columns: readonly ColumnMeta[];
|
|
86
|
-
readonly indexes: readonly IndexMeta[];
|
|
87
|
-
// For tables with composite PK (no single id column, e.g. snapshots
|
|
88
|
-
// keyed by aggregate_id+version). When set, no column should have
|
|
89
|
-
// primaryKey:true; the constraint is emitted at table-level.
|
|
90
|
-
readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
|
|
91
|
-
// Source-hint für Diagnose/Tests — nicht funktional verwendet.
|
|
92
|
-
// "managed" = aus EntityDefinition (mit base-columns + audit-trail).
|
|
93
|
-
// "unmanaged" = via defineUnmanagedTable — kein Standard-Audit, App
|
|
94
|
-
// trägt Verantwortung. Migration-Generator + Tooling können den
|
|
95
|
-
// discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
|
|
96
|
-
readonly source: "managed" | "unmanaged";
|
|
97
|
-
// PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
|
|
98
|
-
// buildEntityTableMeta so the registry can reject r.storeTable stores
|
|
99
|
-
// whose direct writes would skip the executor's encryption (#820).
|
|
100
|
-
readonly piiSubjectFields?: readonly string[];
|
|
101
|
-
};
|
|
32
|
+
export type {
|
|
33
|
+
BuildEntityTableMetaOptions,
|
|
34
|
+
ColumnMeta,
|
|
35
|
+
CompositePrimaryKeyMeta,
|
|
36
|
+
EntityTableMeta,
|
|
37
|
+
IndexMeta,
|
|
38
|
+
PgType,
|
|
39
|
+
UnmanagedTableInput,
|
|
40
|
+
} from "./entity-table-meta-types";
|
|
102
41
|
|
|
103
42
|
// Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
|
|
104
43
|
// `buildBaseColumns()` aus table-builder.ts (drizzle-Variante).
|
|
@@ -293,12 +232,6 @@ export function resolveTableName(
|
|
|
293
232
|
return `${featureName}_${baseName}`;
|
|
294
233
|
}
|
|
295
234
|
|
|
296
|
-
export type BuildEntityTableMetaOptions = {
|
|
297
|
-
readonly featureName?: string;
|
|
298
|
-
readonly relations?: EntityRelations;
|
|
299
|
-
readonly source?: "managed" | "unmanaged";
|
|
300
|
-
};
|
|
301
|
-
|
|
302
235
|
export function buildEntityTableMeta(
|
|
303
236
|
entityName: string,
|
|
304
237
|
entity: EntityDefinition,
|
|
@@ -434,13 +367,6 @@ export function buildEntityTableMeta(
|
|
|
434
367
|
// Heutige use-cases im framework:
|
|
435
368
|
// - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
|
|
436
369
|
// - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
|
|
437
|
-
export type UnmanagedTableInput = {
|
|
438
|
-
readonly tableName: string;
|
|
439
|
-
readonly columns: readonly ColumnMeta[];
|
|
440
|
-
readonly indexes?: readonly IndexMeta[];
|
|
441
|
-
readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
|
|
442
|
-
};
|
|
443
|
-
|
|
444
370
|
function sqlExpressionText(where: unknown): string | undefined {
|
|
445
371
|
if (
|
|
446
372
|
typeof where === "object" &&
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
+
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
1
2
|
import type { LocalKeyKmsAdapter } from "../crypto";
|
|
2
|
-
import type {
|
|
3
|
-
DeleteContext,
|
|
4
|
-
EntityDefinition,
|
|
5
|
-
EntityId,
|
|
6
|
-
SaveContext,
|
|
7
|
-
SessionUser,
|
|
8
|
-
WriteResult,
|
|
9
|
-
} from "../engine/types";
|
|
3
|
+
import type { EntityDefinition } from "../engine/types";
|
|
10
4
|
import type { EntityCache } from "../pipeline/entity-cache";
|
|
11
5
|
import type { SearchAdapter } from "../search/types";
|
|
12
6
|
import type { EnvelopeCipher } from "../secrets/envelope-cipher";
|
|
13
7
|
import { buildExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
8
|
import { createReadVerbs } from "./event-store-executor-read";
|
|
15
9
|
import { createWriteVerbs } from "./event-store-executor-write";
|
|
16
|
-
|
|
10
|
+
|
|
11
|
+
export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
|
|
17
12
|
|
|
18
13
|
// The executor writes events + auto-projection (entity table) in one TX.
|
|
19
14
|
// It no longer knows about user projections — those are driven by the
|
|
@@ -42,93 +37,6 @@ export type EventStoreExecutorOptions = {
|
|
|
42
37
|
kms?: LocalKeyKmsAdapter;
|
|
43
38
|
};
|
|
44
39
|
|
|
45
|
-
export type EventStoreExecutor = {
|
|
46
|
-
create: (
|
|
47
|
-
payload: Record<string, unknown>,
|
|
48
|
-
user: SessionUser,
|
|
49
|
-
db: import("./tenant-db").TenantDb,
|
|
50
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
51
|
-
|
|
52
|
-
update: (
|
|
53
|
-
payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
|
|
54
|
-
user: SessionUser,
|
|
55
|
-
db: import("./tenant-db").TenantDb,
|
|
56
|
-
options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
|
|
57
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
58
|
-
|
|
59
|
-
delete: (
|
|
60
|
-
payload: { id: EntityId },
|
|
61
|
-
user: SessionUser,
|
|
62
|
-
db: import("./tenant-db").TenantDb,
|
|
63
|
-
) => Promise<WriteResult<DeleteContext>>;
|
|
64
|
-
|
|
65
|
-
// Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
|
|
66
|
-
// which hard-deletes the row even for softDelete entities — and, being an
|
|
67
|
-
// auto-verb replayed by the implicit projection, the erasure survives a
|
|
68
|
-
// rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
|
|
69
|
-
forget: (
|
|
70
|
-
payload: { id: EntityId },
|
|
71
|
-
user: SessionUser,
|
|
72
|
-
db: import("./tenant-db").TenantDb,
|
|
73
|
-
) => Promise<WriteResult<DeleteContext>>;
|
|
74
|
-
|
|
75
|
-
restore: (
|
|
76
|
-
payload: { id: EntityId },
|
|
77
|
-
user: SessionUser,
|
|
78
|
-
db: import("./tenant-db").TenantDb,
|
|
79
|
-
) => Promise<WriteResult<SaveContext>>;
|
|
80
|
-
|
|
81
|
-
list: (
|
|
82
|
-
payload: {
|
|
83
|
-
cursor?: string | undefined;
|
|
84
|
-
limit?: number | undefined;
|
|
85
|
-
search?: string | undefined;
|
|
86
|
-
sort?: string | undefined;
|
|
87
|
-
sortDirection?: "asc" | "desc" | undefined;
|
|
88
|
-
offset?: number | undefined;
|
|
89
|
-
totalCount?: boolean | undefined;
|
|
90
|
-
filter?:
|
|
91
|
-
| {
|
|
92
|
-
readonly field: string;
|
|
93
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
94
|
-
readonly value: unknown;
|
|
95
|
-
}
|
|
96
|
-
| undefined;
|
|
97
|
-
// User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
|
|
98
|
-
// `filter`). Alle werden mit AND verknüpft.
|
|
99
|
-
filters?:
|
|
100
|
-
| ReadonlyArray<{
|
|
101
|
-
readonly field: string;
|
|
102
|
-
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
103
|
-
readonly value: unknown;
|
|
104
|
-
}>
|
|
105
|
-
| undefined;
|
|
106
|
-
},
|
|
107
|
-
user: SessionUser,
|
|
108
|
-
db: import("./tenant-db").TenantDb,
|
|
109
|
-
/** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
|
|
110
|
-
* Executor beim Build keinen SearchAdapter via Options bekommen
|
|
111
|
-
* hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
|
|
112
|
-
* hier zur Runtime einen aus ctx.searchAdapter durchreichen.
|
|
113
|
-
* options.searchAdapter (build-time) gewinnt — runtime-Override
|
|
114
|
-
* ist Fallback für die default-Wrapper. */
|
|
115
|
-
runtimeOptions?: {
|
|
116
|
-
readonly searchAdapter?: SearchAdapter;
|
|
117
|
-
// Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
|
|
118
|
-
// deleted rows are returned too. Tenant + ownership clauses still apply
|
|
119
|
-
// — includeDeleted only relaxes the soft-delete predicate, never the
|
|
120
|
-
// visibility ones, so it can ride untrusted query input safely.
|
|
121
|
-
readonly includeDeleted?: boolean;
|
|
122
|
-
},
|
|
123
|
-
) => Promise<CursorResult<Record<string, unknown>>>;
|
|
124
|
-
|
|
125
|
-
detail: (
|
|
126
|
-
payload: { id: EntityId },
|
|
127
|
-
user: SessionUser,
|
|
128
|
-
db: import("./tenant-db").TenantDb,
|
|
129
|
-
) => Promise<Record<string, unknown> | null>;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
40
|
export function createEventStoreExecutor(
|
|
133
41
|
table: Table,
|
|
134
42
|
entity: EntityDefinition,
|
|
@@ -47,6 +47,7 @@ export type ConsumerDeliveryOutcome = {
|
|
|
47
47
|
readonly attempts: number;
|
|
48
48
|
readonly lastError: string | null;
|
|
49
49
|
readonly deadLettered: boolean;
|
|
50
|
+
readonly processed: number;
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
export async function updateConsumerDeliveryOutcome(
|
|
@@ -55,19 +56,26 @@ export async function updateConsumerDeliveryOutcome(
|
|
|
55
56
|
instanceId: string,
|
|
56
57
|
outcome: ConsumerDeliveryOutcome,
|
|
57
58
|
): Promise<void> {
|
|
59
|
+
// Advancing the cursor proves this pass wasn't poison — reset the re-arm
|
|
60
|
+
// budget so an unrelated failure down the line gets its own fresh 3
|
|
61
|
+
// chances instead of inheriting a partially-spent counter from a past,
|
|
62
|
+
// already-resolved outage.
|
|
63
|
+
const resetRearmCount = outcome.processed > 0;
|
|
58
64
|
await asRawClient(db).unsafe(
|
|
59
65
|
`UPDATE "kumiko_event_consumers" SET
|
|
60
66
|
"last_processed_event_id" = $1,
|
|
61
67
|
"attempts" = $2,
|
|
62
68
|
"status" = $3,
|
|
63
69
|
"last_error" = $4,
|
|
70
|
+
"rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
|
|
64
71
|
"updated_at" = now()
|
|
65
|
-
WHERE "name" = $
|
|
72
|
+
WHERE "name" = $6 AND "instance_id" = $7`,
|
|
66
73
|
[
|
|
67
74
|
outcome.cursor,
|
|
68
75
|
outcome.attempts,
|
|
69
76
|
outcome.deadLettered ? "dead" : "idle",
|
|
70
77
|
outcome.lastError,
|
|
78
|
+
resetRearmCount,
|
|
71
79
|
name,
|
|
72
80
|
instanceId,
|
|
73
81
|
],
|
|
@@ -81,7 +89,7 @@ export async function updateConsumerStatusReturning(
|
|
|
81
89
|
status: "idle" | "disabled",
|
|
82
90
|
): Promise<Record<string, unknown> | undefined> {
|
|
83
91
|
const rows = (await asRawClient(db).unsafe(
|
|
84
|
-
`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "updated_at" = now()
|
|
92
|
+
`UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
|
|
85
93
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
86
94
|
RETURNING *`,
|
|
87
95
|
[status, name, instanceId],
|
|
@@ -101,6 +109,7 @@ export async function advanceConsumerPastEventReturning(
|
|
|
101
109
|
"status" = 'idle',
|
|
102
110
|
"attempts" = 0,
|
|
103
111
|
"last_error" = NULL,
|
|
112
|
+
"rearm_count" = 0,
|
|
104
113
|
"updated_at" = now()
|
|
105
114
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
106
115
|
RETURNING *`,
|
|
@@ -168,3 +177,27 @@ export async function markConsumerRebuildFailed(
|
|
|
168
177
|
[errorMessage, name, instanceId],
|
|
169
178
|
);
|
|
170
179
|
}
|
|
180
|
+
|
|
181
|
+
// Auto-revive a dead consumer once its cooldown has elapsed (called from
|
|
182
|
+
// acquireConsumerState — not an ops action, so no "requireConsumerRow"
|
|
183
|
+
// precondition like restartConsumer/enableConsumer). Increments rearm_count
|
|
184
|
+
// so the caller can enforce a lifetime cap on automatic revivals; manual
|
|
185
|
+
// restartConsumer()/enableConsumer() reset it back to 0.
|
|
186
|
+
export async function rearmDeadConsumer(
|
|
187
|
+
db: AnyDb,
|
|
188
|
+
name: string,
|
|
189
|
+
instanceId: string,
|
|
190
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
191
|
+
const rows = (await asRawClient(db).unsafe(
|
|
192
|
+
`UPDATE "kumiko_event_consumers" SET
|
|
193
|
+
"status" = 'idle',
|
|
194
|
+
"attempts" = 0,
|
|
195
|
+
"last_error" = NULL,
|
|
196
|
+
"rearm_count" = "rearm_count" + 1,
|
|
197
|
+
"updated_at" = now()
|
|
198
|
+
WHERE "name" = $1 AND "instance_id" = $2
|
|
199
|
+
RETURNING *`,
|
|
200
|
+
[name, instanceId],
|
|
201
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
202
|
+
return rows[0];
|
|
203
|
+
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
1
2
|
import type {
|
|
2
3
|
EntityDefinition,
|
|
3
4
|
EntityRelations,
|
|
@@ -382,25 +383,7 @@ type SoftDeleteColumnsType = {
|
|
|
382
383
|
};
|
|
383
384
|
|
|
384
385
|
// ── ES-write brand ──────────────────────────────────────────────────────
|
|
385
|
-
|
|
386
|
-
// rebuild-safe). To make a direct write a *compile* error rather than a
|
|
387
|
-
// convention, EntityTable carries a phantom `unique symbol` prop; the public
|
|
388
|
-
// write helpers reject anything that has it (see NotExecutorOnly + query.ts).
|
|
389
|
-
// The symbol key dodges SchemaTable's `[field: string]: unknown` index
|
|
390
|
-
// signature (string index sigs don't cover symbol keys), so the brand
|
|
391
|
-
// survives the type-erasure that would swallow a plain marker prop — and the
|
|
392
|
-
// executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
|
|
393
|
-
// carries no such prop, so the one legitimate writer stays green.
|
|
394
|
-
declare const EXECUTOR_ONLY: unique symbol;
|
|
395
|
-
export interface ExecutorOnly {
|
|
396
|
-
readonly [EXECUTOR_ONLY]: true;
|
|
397
|
-
}
|
|
398
|
-
// Negative brand for write-helper params: a branded EntityTable is NOT
|
|
399
|
-
// assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
|
|
400
|
-
// and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
|
|
401
|
-
export type NotExecutorOnly = {
|
|
402
|
-
readonly [EXECUTOR_ONLY]?: never;
|
|
403
|
-
};
|
|
386
|
+
export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
|
|
404
387
|
|
|
405
388
|
export type EntityTable<E extends EntityDefinition = EntityDefinition> =
|
|
406
389
|
TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
+
import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
1
3
|
import {
|
|
2
4
|
asEntityTableMeta,
|
|
3
5
|
asRawClient,
|
|
@@ -12,61 +14,10 @@ import {
|
|
|
12
14
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
13
15
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
14
16
|
import type { DbRunner } from "./connection";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
// biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
|
|
20
|
-
type Table = TableColumns<any>;
|
|
21
|
-
|
|
22
|
-
// Method-form writes reject the executor-only brand exactly like the free-function
|
|
23
|
-
// helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
|
|
24
|
-
// directly — free-function OR method-form — drifts the row past its event stream and
|
|
25
|
-
// a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
|
|
26
|
-
// metas are not projections → writable); `& NotExecutorOnly` strips only branded
|
|
27
|
-
// EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
|
|
28
|
-
// the plain `Table` param.
|
|
29
|
-
type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* TenantDb scope modes:
|
|
33
|
-
*
|
|
34
|
-
* - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
|
|
35
|
-
* INSERT forces tenantId — handler cannot override.
|
|
36
|
-
*
|
|
37
|
-
* - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
|
|
38
|
-
* INSERT uses tenantId as default but handler can override.
|
|
39
|
-
*
|
|
40
|
-
* Tables without a tenantId column are always unfiltered regardless of mode.
|
|
41
|
-
*/
|
|
42
|
-
export type TenantDbMode = "tenant" | "system";
|
|
43
|
-
|
|
44
|
-
export type TenantDb = {
|
|
45
|
-
readonly tenantId: TenantId;
|
|
46
|
-
readonly mode: TenantDbMode;
|
|
47
|
-
/**
|
|
48
|
-
* Underlying DbRunner. Framework-internal use (event-store, migrations) —
|
|
49
|
-
* bypasses tenant-filter. Feature code uses the typed helpers above so the
|
|
50
|
-
* automatic scoping stays intact.
|
|
51
|
-
*/
|
|
52
|
-
readonly raw: DbRunner;
|
|
53
|
-
selectMany<T = Record<string, unknown>>(
|
|
54
|
-
table: Table,
|
|
55
|
-
where?: WhereObject,
|
|
56
|
-
options?: SelectOptions,
|
|
57
|
-
): Promise<readonly T[]>;
|
|
58
|
-
fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
|
|
59
|
-
insertOne<T = Record<string, unknown>>(
|
|
60
|
-
table: WritableTable,
|
|
61
|
-
values: Record<string, unknown>,
|
|
62
|
-
): Promise<T | undefined>;
|
|
63
|
-
updateMany<T = Record<string, unknown>>(
|
|
64
|
-
table: WritableTable,
|
|
65
|
-
set: Record<string, unknown>,
|
|
66
|
-
where: WhereObject,
|
|
67
|
-
): Promise<readonly T[]>;
|
|
68
|
-
deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
|
|
69
|
-
};
|
|
17
|
+
|
|
18
|
+
type Table = SchemaTable;
|
|
19
|
+
|
|
20
|
+
export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
70
21
|
|
|
71
22
|
// @cast-boundary tenant-db-row
|
|
72
23
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// r.bootCheck(fn) — feature-declared mount invariants, checked at boot.
|
|
2
|
+
// Mirrors the prompt-store trap (kumiko-enterprise#229): a feature with
|
|
3
|
+
// PII-annotated fields was mounted without its required companion feature,
|
|
4
|
+
// and nothing caught it at boot. The conditional-invariant tests below
|
|
5
|
+
// reproduce that shape: the check only fails when the feature actually has
|
|
6
|
+
// PII fields AND the companion is missing — a bare "is X mounted" check
|
|
7
|
+
// would already be covered by r.requires and wouldn't justify this API.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { validateFeatureBootChecks } from "../boot-validator/boot-check";
|
|
11
|
+
import { defineFeature } from "../define-feature";
|
|
12
|
+
import { createEntity, createTextField } from "../factories";
|
|
13
|
+
|
|
14
|
+
function catchMessage(fn: () => void): string {
|
|
15
|
+
try {
|
|
16
|
+
fn();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return e instanceof Error ? e.message : String(e);
|
|
19
|
+
}
|
|
20
|
+
throw new Error("expected function to throw, but it did not");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
|
|
24
|
+
features.some((f) => f.name === "user-data-hook");
|
|
25
|
+
|
|
26
|
+
const promptStore = () =>
|
|
27
|
+
defineFeature("prompt-store", (r) => {
|
|
28
|
+
const promptFields = { text: createTextField({ pii: true }) };
|
|
29
|
+
r.entity("prompt", createEntity({ fields: promptFields }));
|
|
30
|
+
r.bootCheck(({ features }) => {
|
|
31
|
+
// Conditional on this feature's own shape (has a pii field), closed
|
|
32
|
+
// over from setup — r.requires("user-data-hook") can't express that.
|
|
33
|
+
const hasPiiField = Object.values(promptFields).some((field) => field.pii);
|
|
34
|
+
if (hasPiiField && !requiresUserDataHook(features)) {
|
|
35
|
+
throw new Error("prompt-store has PII fields but no user-data-hook feature is mounted");
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("r.bootCheck / validateFeatureBootChecks", () => {
|
|
41
|
+
test("no bootChecks registered → no-op", () => {
|
|
42
|
+
const noop = defineFeature("noop", () => {});
|
|
43
|
+
expect(() => validateFeatureBootChecks([noop])).not.toThrow();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("conditional invariant satisfied (companion mounted) → boot succeeds", () => {
|
|
47
|
+
const userDataHook = defineFeature("user-data-hook", () => {});
|
|
48
|
+
expect(() => validateFeatureBootChecks([userDataHook, promptStore()])).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("conditional invariant violated (PII field, no companion) → boot fails with feature-prefixed message", () => {
|
|
52
|
+
const message = catchMessage(() => validateFeatureBootChecks([promptStore()]));
|
|
53
|
+
expect(message).toContain("[Feature prompt-store]");
|
|
54
|
+
expect(message).toContain(
|
|
55
|
+
"prompt-store has PII fields but no user-data-hook feature is mounted",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("no PII field → boot succeeds even without the companion", () => {
|
|
60
|
+
const noPiiFields = { text: createTextField() };
|
|
61
|
+
const noPii = defineFeature("prompt-store-no-pii", (r) => {
|
|
62
|
+
r.entity("note", createEntity({ fields: noPiiFields }));
|
|
63
|
+
r.bootCheck(({ features }) => {
|
|
64
|
+
const hasPiiField = Object.values(noPiiFields).some((field) => field.pii);
|
|
65
|
+
if (hasPiiField && !requiresUserDataHook(features)) {
|
|
66
|
+
throw new Error("unreachable in this test");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
expect(() => validateFeatureBootChecks([noPii])).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("multiple bootChecks on one feature all run in order until one throws", () => {
|
|
74
|
+
const calls: string[] = [];
|
|
75
|
+
const feature = defineFeature("multi-check", (r) => {
|
|
76
|
+
r.bootCheck(() => {
|
|
77
|
+
calls.push("first");
|
|
78
|
+
});
|
|
79
|
+
r.bootCheck(() => {
|
|
80
|
+
calls.push("second");
|
|
81
|
+
throw new Error("second check failed");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
expect(() => validateFeatureBootChecks([feature])).toThrow("second check failed");
|
|
85
|
+
expect(calls).toEqual(["first", "second"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("ctx.features exposes every mounted feature, not just the declaring one", () => {
|
|
89
|
+
const a = defineFeature("feature-a", () => {});
|
|
90
|
+
let seenNames: string[] = [];
|
|
91
|
+
const b = defineFeature("feature-b", (r) => {
|
|
92
|
+
r.bootCheck(({ features }) => {
|
|
93
|
+
seenNames = features.map((f) => f.name);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
validateFeatureBootChecks([a, b]);
|
|
97
|
+
expect(seenNames).toEqual(["feature-a", "feature-b"]);
|
|
98
|
+
});
|
|
99
|
+
});
|