@cosmicdrift/kumiko-framework 0.155.0 → 0.156.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.
Files changed (74) hide show
  1. package/README.md +9 -38
  2. package/package.json +2 -2
  3. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  4. package/src/__tests__/raw-table.integration.test.ts +17 -40
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +77 -0
  6. package/src/crypto/index.ts +1 -0
  7. package/src/crypto/pii-field-encryption.ts +19 -0
  8. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
  9. package/src/db/__tests__/blind-index.integration.test.ts +16 -0
  10. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  11. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  12. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  13. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  15. package/src/db/collect-table-metas.ts +6 -7
  16. package/src/db/entity-table-meta.ts +1 -1
  17. package/src/db/feature-table-sources.ts +5 -11
  18. package/src/db/migrate-generator.ts +39 -6
  19. package/src/db/queries/shadow-swap.ts +85 -3
  20. package/src/db/tenant-db.ts +8 -0
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  22. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  23. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  24. package/src/engine/__tests__/field-access.test.ts +23 -1
  25. package/src/engine/__tests__/raw-table.test.ts +134 -84
  26. package/src/engine/__tests__/registry.test.ts +40 -0
  27. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  28. package/src/engine/boot-validator/action-wiring.ts +2 -2
  29. package/src/engine/boot-validator/config-deps.ts +35 -0
  30. package/src/engine/boot-validator/index.ts +2 -0
  31. package/src/engine/boot-validator/pii-retention.ts +35 -3
  32. package/src/engine/build-config-feature-schema.ts +3 -3
  33. package/src/engine/config-helpers.ts +2 -0
  34. package/src/engine/define-feature.ts +2 -6
  35. package/src/engine/entity-handlers.ts +2 -4
  36. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  37. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  38. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  39. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  40. package/src/engine/feature-ast/extractors/events.ts +322 -0
  41. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  42. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  43. package/src/engine/feature-ast/extractors/index.ts +32 -24
  44. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  45. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  46. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  47. package/src/engine/feature-ast/parse.ts +3 -3
  48. package/src/engine/feature-ast/render.ts +16 -11
  49. package/src/engine/feature-builder-state.ts +0 -3
  50. package/src/engine/feature-config-events-jobs.ts +3 -6
  51. package/src/engine/feature-entity-handlers.ts +9 -1
  52. package/src/engine/feature-ui-extensions.ts +10 -40
  53. package/src/engine/field-access.ts +13 -2
  54. package/src/engine/object-form.ts +15 -0
  55. package/src/engine/registry-facade.ts +4 -0
  56. package/src/engine/registry-ingest.ts +19 -29
  57. package/src/engine/registry-state.ts +1 -7
  58. package/src/engine/registry-validate.ts +5 -28
  59. package/src/engine/registry.ts +0 -2
  60. package/src/engine/types/config.ts +7 -0
  61. package/src/engine/types/feature.ts +40 -68
  62. package/src/engine/types/fields.ts +6 -0
  63. package/src/engine/types/index.ts +0 -3
  64. package/src/es-ops/README.md +1 -1
  65. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  66. package/src/es-ops/context.ts +4 -2
  67. package/src/es-ops/types.ts +8 -1
  68. package/src/pipeline/msp-rebuild.ts +4 -0
  69. package/src/pipeline/projection-rebuild.ts +35 -0
  70. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  71. package/src/stack/test-stack.ts +17 -2
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  74. package/src/engine/feature-ast/extractors/round4.ts +0 -1227
@@ -42,13 +42,15 @@ export function createSeedMigrationContext(
42
42
  const defaultSystemUser = createSystemUser(SYSTEM_TENANT_ID);
43
43
 
44
44
  return {
45
- systemWriteAs: async (handlerQualifiedName, payload, tenantIdOverride) => {
45
+ systemWriteAs: async (handlerQualifiedName, payload, tenantIdOverride, extraRoles) => {
46
46
  // tenantIdOverride: baut einen System-User mit der Stream-tenantId
47
47
  // damit der Event-Store-Executor das Aggregate im richtigen Stream
48
48
  // findet. Verhindert die version_conflict-Falle (siehe Memory
49
49
  // feedback_event_store_tenant_consistency.md).
50
50
  const executor =
51
- tenantIdOverride !== undefined ? createSystemUser(tenantIdOverride) : defaultSystemUser;
51
+ tenantIdOverride !== undefined || (extraRoles && extraRoles.length > 0)
52
+ ? createSystemUser(tenantIdOverride ?? SYSTEM_TENANT_ID, extraRoles)
53
+ : defaultSystemUser;
52
54
  const result = await args.dispatcher.write(handlerQualifiedName, payload, executor);
53
55
  // Critical: WriteResult{isSuccess: false} würde sonst silent durchlaufen
54
56
  // → Marker landet trotz failed-Write → Migration falsch als "applied"
@@ -95,11 +95,18 @@ export type SeedMigrationContext = {
95
95
  * tenantIdOverride weglassen (Default SYSTEM_TENANT_ID).
96
96
  * - Tenant-scope-Aggregate (memberships, tenant-config, app-data) →
97
97
  * `tenantIdOverride: m.tenantId` (oder den Stream-Tenant aus
98
- * einem find*-Helper). */
98
+ * einem find*-Helper).
99
+ *
100
+ * **extraRoles:** hasAccess has no system-bypass — a handler with an
101
+ * explicit role gate (e.g. `access: { roles: ["SystemAdmin"] }` or
102
+ * `["anonymous"]`) otherwise rejects the bare `system` actor with
103
+ * `access_denied`. Pass the required role(s) here — see
104
+ * `createSystemUser`'s `extraRoles` doc. */
99
105
  readonly systemWriteAs: (
100
106
  handlerQualifiedName: string,
101
107
  payload: unknown,
102
108
  tenantIdOverride?: TenantId,
109
+ extraRoles?: readonly string[],
103
110
  ) => Promise<WriteResult>;
104
111
 
105
112
  // Read-helpers für die häufigsten Lookups. Wachsen on-demand —
@@ -247,6 +247,10 @@ export async function rebuildMultiStreamProjection(
247
247
  eventsSkipped: skipped.length,
248
248
  lastProcessedEventId,
249
249
  durationMs: Date.now() - startedAt,
250
+ // countColumnDrift is single-table (public.<t> vs kumiko_rebuild.<t>) and
251
+ // not wired into the multi-stream rebuild path — MSPs don't run the
252
+ // implicit-projection ghost-row guard either. #916 scope is single-table.
253
+ columnDriftCount: 0,
250
254
  };
251
255
  if (deps.meter) {
252
256
  emitProjectionRebuild(
@@ -10,6 +10,8 @@ import {
10
10
  assertLiveColumnsMatchMeta,
11
11
  assertNoUnreachableLiveRows,
12
12
  buildShadowTable,
13
+ type ColumnDriftResult,
14
+ countColumnDrift,
13
15
  ensureRebuildSchema,
14
16
  fenceLiveTable,
15
17
  rebuildMetaOrThrow,
@@ -134,6 +136,11 @@ export type RebuildResult = {
134
136
  readonly eventsSkipped: number;
135
137
  readonly lastProcessedEventId: bigint;
136
138
  readonly durationMs: number;
139
+ // Live rows whose columns differ from the freshly-replayed shadow (bidx
140
+ // columns excluded — see countColumnDrift). Non-blocking: the swap
141
+ // proceeds regardless (#916). 0 for explicit projections (guard is
142
+ // implicit-only) or when no drift was found.
143
+ readonly columnDriftCount: number;
137
144
  };
138
145
 
139
146
  type RebuildDeps = {
@@ -178,6 +185,27 @@ type RebuildDeps = {
178
185
  readonly __test_onBuildShadowTable?: () => void;
179
186
  };
180
187
 
188
+ // Ops-facing log line for countColumnDrift's result. No-op when clean. #916.
189
+ function warnOnColumnDrift(
190
+ projectionName: string,
191
+ tableName: string,
192
+ drift: ColumnDriftResult,
193
+ ): void {
194
+ // skip: no drift found — nothing to warn about
195
+ if (drift.rowCount === 0) return;
196
+ const sampledIds = new Set(drift.sample.map((s) => s.slice(0, s.lastIndexOf("."))));
197
+ const countLabel =
198
+ sampledIds.size < drift.rowCount
199
+ ? `${drift.rowCount} (sample truncated)`
200
+ : String(drift.rowCount);
201
+ console.warn(
202
+ `projection-rebuild "${projectionName}": ${countLabel} live row(s) in "${tableName}" have ` +
203
+ `column values that differ from the freshly-replayed shadow (sample: ${drift.sample.join(", ")}). ` +
204
+ "Not blocking — replay wins on swap. Likely cause: a legacy direct-write predating the row's " +
205
+ "event history (#494), or a sensitive custom field pending #972. Investigate if unexpected.",
206
+ );
207
+ }
208
+
181
209
  export async function rebuildProjection(
182
210
  projectionName: string,
183
211
  deps: RebuildDeps,
@@ -205,6 +233,7 @@ export async function rebuildProjection(
205
233
  const fenceLockTimeoutMs = deps.fenceLockTimeoutMs ?? DEFAULT_FENCE_LOCK_TIMEOUT_MS;
206
234
  const startedAt = Date.now();
207
235
  let eventsProcessed = 0;
236
+ let columnDriftCount = 0;
208
237
  let lastProcessedEventId = 0n;
209
238
  const skipApplyErrors = deps.errorPolicy?.skipApplyErrors === true;
210
239
  // Quarantined events, collected in memory and written once after the
@@ -332,6 +361,11 @@ export async function rebuildProjection(
332
361
  // which the swap would silently drop. Implicit projections only.
333
362
  if (projection.isImplicit === true) {
334
363
  await assertNoUnreachableLiveRows(tx, projectionName, meta.tableName, sourcesList);
364
+ // Non-blocking counterpart: reports column-level drift (bidx excluded)
365
+ // without aborting — see countColumnDrift for why this isn't fail-hard.
366
+ const drift = await countColumnDrift(tx, meta.tableName, meta);
367
+ columnDriftCount = drift.rowCount;
368
+ warnOnColumnDrift(projectionName, meta.tableName, drift);
335
369
  }
336
370
  await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
337
371
  await swapShadowIntoLive(tx, meta.tableName);
@@ -361,6 +395,7 @@ export async function rebuildProjection(
361
395
  eventsSkipped: skipped.length,
362
396
  lastProcessedEventId,
363
397
  durationMs: Date.now() - startedAt,
398
+ columnDriftCount,
364
399
  };
365
400
  if (deps.meter) {
366
401
  emitProjectionRebuild(
@@ -9,7 +9,7 @@ const MEILI_KEY = process.env["MEILI_MASTER_KEY"] ?? "kumiko-dev-key";
9
9
 
10
10
  async function meiliAvailable(): Promise<boolean> {
11
11
  try {
12
- const health = await fetch(`${MEILI_URL}/health`);
12
+ const health = await fetch(`${MEILI_URL}/health`, { signal: AbortSignal.timeout(2000) });
13
13
  return health.ok;
14
14
  } catch {
15
15
  return false;
@@ -17,8 +17,15 @@ async function meiliAvailable(): Promise<boolean> {
17
17
  }
18
18
 
19
19
  // skipIf when compose Meili is down — unit tests still cover index/doc id shaping.
20
+ // In a dedicated integration job (REQUIRE_MEILI=1) a down Meili is a hard
21
+ // failure, not a silent skip — the compose service is expected to be up there.
20
22
  const MEILI_UP = await meiliAvailable();
21
23
  if (!MEILI_UP) {
24
+ if (process.env["REQUIRE_MEILI"] === "1") {
25
+ throw new Error(
26
+ `REQUIRE_MEILI=1 but Meilisearch not reachable at ${MEILI_URL} — integration job misconfigured`,
27
+ );
28
+ }
22
29
  console.warn(
23
30
  `Meilisearch not reachable at ${MEILI_URL} — skipping live adapter tests ` +
24
31
  `(docker compose up -d meilisearch, default port 17700)`,
@@ -409,8 +409,13 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
409
409
  },
410
410
  };
411
411
  } catch (error) {
412
- // Best-effort — a broken jobRunner.stop() must never mask the real
413
- // setup failure below.
412
+ // Best-effort — a broken jobRunner.stop()/cleanup() must never mask the
413
+ // real setup failure below. testDb/testRedis are created before this
414
+ // try even starts, so a throw anywhere in setup (buildServer,
415
+ // search-config, ...) would otherwise leak their open pg/ioredis
416
+ // sockets — the caller never gets the stack object back to call
417
+ // cleanup() itself, which is the same "hangs on exit" failure class
418
+ // this function exists to prevent.
414
419
  if (jobRunner) {
415
420
  try {
416
421
  await jobRunner.stop();
@@ -418,6 +423,16 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
418
423
  // ignore — `error` is the one that matters
419
424
  }
420
425
  }
426
+ try {
427
+ await testDb.cleanup();
428
+ } catch {
429
+ // ignore — `error` is the one that matters
430
+ }
431
+ try {
432
+ await testRedis.cleanup();
433
+ } catch {
434
+ // ignore — `error` is the one that matters
435
+ }
421
436
  throw error;
422
437
  }
423
438
  }
@@ -89,4 +89,5 @@ export {
89
89
  export type { TargetRef } from "../engine/types/target-ref";
90
90
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
91
91
  export type { WorkspaceDefinition } from "../engine/types/workspace";
92
+ export { PROJECTION_DETAIL_ENTITY } from "../i18n/required-surface-keys";
92
93
  export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";
@@ -1,172 +0,0 @@
1
- // Unit tests for r.unmanagedTable() — the EntityTableMeta cousin of
2
- // r.rawTable. Same audit-trail contract, different storage shape (post-
3
- // drizzle migrate-runner). See define-feature.ts / DX-4.
4
-
5
- import { describe, expect, test } from "bun:test";
6
- import {
7
- buildEntityTableMeta,
8
- defineUnmanagedTable,
9
- resolveTableName,
10
- } from "../../db/entity-table-meta";
11
- import { defineFeature } from "../define-feature";
12
- import { createEntity, createTextField } from "../index";
13
- import { createRegistry } from "../registry";
14
-
15
- const probeMeta = defineUnmanagedTable({
16
- tableName: "ut_probe",
17
- columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
18
- });
19
- const probeMetaTwo = defineUnmanagedTable({
20
- tableName: "ut_probe_two",
21
- columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
22
- });
23
-
24
- describe("r.unmanagedTable — declaration", () => {
25
- test("rejects duplicate registrations within one feature", () => {
26
- expect(() =>
27
- defineFeature("probe", (r) => {
28
- r.unmanagedTable(probeMeta, { reason: "test" });
29
- r.unmanagedTable(probeMeta, { reason: "test" });
30
- }),
31
- ).toThrow(/already registered/);
32
- });
33
-
34
- test("rejects empty reason", () => {
35
- expect(() =>
36
- defineFeature("probe", (r) => {
37
- r.unmanagedTable(probeMeta, { reason: "" });
38
- }),
39
- ).toThrow(/options\.reason must be a non-empty string/);
40
- });
41
-
42
- test("rejects whitespace-only reason", () => {
43
- expect(() =>
44
- defineFeature("probe", (r) => {
45
- r.unmanagedTable(probeMeta, { reason: " " });
46
- }),
47
- ).toThrow(/options\.reason must be a non-empty string/);
48
- });
49
-
50
- test("accepts valid registration and stores meta + reason", () => {
51
- const feature = defineFeature("probe", (r) => {
52
- r.unmanagedTable(probeMeta, {
53
- reason: "read-side projection of an event-stream",
54
- });
55
- });
56
- expect(feature.unmanagedTables).toHaveProperty("ut_probe");
57
- expect(feature.unmanagedTables["ut_probe"]?.reason).toBe(
58
- "read-side projection of an event-stream",
59
- );
60
- expect(feature.unmanagedTables["ut_probe"]?.meta).toBe(probeMeta);
61
- });
62
-
63
- test("two unmanaged tables on one feature register under their tableName", () => {
64
- const feature = defineFeature("dual", (r) => {
65
- r.unmanagedTable(probeMeta, { reason: "one" });
66
- r.unmanagedTable(probeMetaTwo, { reason: "two" });
67
- });
68
- expect(Object.keys(feature.unmanagedTables).sort()).toEqual(["ut_probe", "ut_probe_two"]);
69
- });
70
-
71
- test("absent unmanagedTables on a feature is ok", () => {
72
- const feat = defineFeature("plain", () => {
73
- // no r.unmanagedTable calls
74
- });
75
- expect(feat.unmanagedTables).toEqual({});
76
- });
77
- });
78
-
79
- describe("createRegistry — unmanagedTable aggregation", () => {
80
- test("rejects cross-feature tableName collisions at boot", () => {
81
- // Two features can't share the same physical tableName — migrate-runner
82
- // would race two CREATE TABLE statements. Boot-validator catches it.
83
- const featA = defineFeature("a", (r) => {
84
- r.unmanagedTable(probeMeta, { reason: "first" });
85
- });
86
- const featB = defineFeature("b", (r) => {
87
- r.unmanagedTable(probeMeta, { reason: "second" });
88
- });
89
- expect(() => createRegistry([featA, featB])).toThrow(
90
- /Unmanaged-table "ut_probe" registered by both feature "a" and "b"/,
91
- );
92
- });
93
-
94
- test("two features with distinct tableNames register cleanly", () => {
95
- const featA = defineFeature("a", (r) => {
96
- r.unmanagedTable(probeMeta, { reason: "first" });
97
- });
98
- const featB = defineFeature("b", (r) => {
99
- r.unmanagedTable(probeMetaTwo, { reason: "second" });
100
- });
101
- expect(() => createRegistry([featA, featB])).not.toThrow();
102
- });
103
-
104
- test("rejects an unmanaged-table that collides with an entity's physical name", () => {
105
- const widget = createEntity({ fields: { name: createTextField() } });
106
- // resolveTableName mirrors the migrate-runner — pin the exact physical name.
107
- const physical = resolveTableName("widget", widget, "shop");
108
- const clashing = defineUnmanagedTable({
109
- tableName: physical,
110
- columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
111
- });
112
- const entityFeature = defineFeature("shop", (r) => {
113
- r.entity("widget", widget);
114
- });
115
- const tableFeature = defineFeature("other", (r) => {
116
- r.unmanagedTable(clashing, { reason: "clash" });
117
- });
118
-
119
- // Entity registered first, then the colliding unmanaged table.
120
- expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
121
- new RegExp(
122
- `Unmanaged-table "${physical}".*collides with the physical table of entity "widget"`,
123
- ),
124
- );
125
-
126
- // Order-independent: unmanaged table registered first, then the entity.
127
- expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
128
- new RegExp(`Entity "widget".*collides with r.unmanagedTable\\("${physical}"\\)`),
129
- );
130
- });
131
- });
132
-
133
- describe("createRegistry — unmanaged tables with PII-annotated fields (#820)", () => {
134
- const piiEntity = createEntity({
135
- table: "ut_pii_probe",
136
- fields: {
137
- userId: createTextField({ required: true }),
138
- ip: createTextField({ userOwned: { ownerField: "userId" } }),
139
- },
140
- });
141
- const piiMeta = buildEntityTableMeta("ut-pii-probe", piiEntity);
142
-
143
- test("buildEntityTableMeta records the subject-annotated field names", () => {
144
- expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
145
- });
146
-
147
- test("rejects a PII-carrying unmanaged table without piiEncryptedOnWrite", () => {
148
- const feat = defineFeature("probe", (r) => {
149
- r.unmanagedTable(piiMeta, { reason: "direct-write store" });
150
- });
151
- expect(() => createRegistry([feat])).toThrow(
152
- /has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
153
- );
154
- });
155
-
156
- test("accepts it once the feature declares piiEncryptedOnWrite", () => {
157
- const feat = defineFeature("probe", (r) => {
158
- r.unmanagedTable(piiMeta, {
159
- reason: "direct-write store",
160
- piiEncryptedOnWrite: true,
161
- });
162
- });
163
- expect(() => createRegistry([feat])).not.toThrow();
164
- });
165
-
166
- test("a PII-free unmanaged table needs no declaration", () => {
167
- const feat = defineFeature("probe", (r) => {
168
- r.unmanagedTable(probeMeta, { reason: "plain store" });
169
- });
170
- expect(() => createRegistry([feat])).not.toThrow();
171
- });
172
- });