@cosmicdrift/kumiko-framework 0.155.1 → 0.156.1
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/README.md +9 -38
- package/package.json +2 -2
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/raw-table.integration.test.ts +17 -40
- package/src/crypto/__tests__/pii-field-encryption.test.ts +105 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +19 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
- package/src/db/__tests__/blind-index.integration.test.ts +16 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/event-store-executor-write.ts +23 -2
- package/src/db/event-store-executor.ts +1 -1
- package/src/db/feature-table-sources.ts +5 -11
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/queries/shadow-swap.ts +85 -3
- package/src/db/tenant-db.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/raw-table.test.ts +134 -84
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -2
- package/src/engine/boot-validator/config-deps.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +35 -3
- package/src/engine/build-config-feature-schema.ts +3 -3
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +2 -6
- package/src/engine/entity-handlers.ts +11 -5
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +222 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +32 -24
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-ast/render.ts +16 -11
- package/src/engine/feature-builder-state.ts +0 -3
- package/src/engine/feature-config-events-jobs.ts +3 -6
- package/src/engine/feature-entity-handlers.ts +9 -1
- package/src/engine/feature-ui-extensions.ts +10 -40
- package/src/engine/field-access.ts +13 -2
- package/src/engine/object-form.ts +15 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/registry-ingest.ts +19 -29
- package/src/engine/registry-state.ts +1 -7
- package/src/engine/registry-validate.ts +5 -28
- package/src/engine/registry.ts +0 -2
- package/src/engine/types/config.ts +7 -0
- package/src/engine/types/feature.ts +40 -68
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +0 -3
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
- package/src/pipeline/msp-rebuild.ts +4 -0
- package/src/pipeline/projection-rebuild.ts +35 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
- package/src/stack/test-stack.ts +17 -2
- package/src/ui-types/index.ts +1 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
- package/src/engine/feature-ast/extractors/round4.ts +0 -1227
package/src/es-ops/context.ts
CHANGED
|
@@ -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
|
|
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"
|
package/src/es-ops/types.ts
CHANGED
|
@@ -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 —
|
|
@@ -100,6 +100,35 @@ describe("dispatcher.write", () => {
|
|
|
100
100
|
expect(result.isSuccess).toBe(true);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
test("user-bucketed handler without RateLimitResolver → internal_error", async () => {
|
|
104
|
+
// Misconfig: a handler opts into L3 rate-limit but the app never loaded the
|
|
105
|
+
// rate-limiting feature. User buckets cannot skip (unlike ip+no-IP), so we
|
|
106
|
+
// fail loud at first write instead of silently running uncapped.
|
|
107
|
+
const rlFeature = defineFeature("rl-user", (r) => {
|
|
108
|
+
r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
|
|
109
|
+
r.writeHandler(
|
|
110
|
+
"item:create",
|
|
111
|
+
z.object({ name: z.string() }),
|
|
112
|
+
async (event) => ({ isSuccess: true, data: { name: event.payload.name } }),
|
|
113
|
+
{
|
|
114
|
+
access: { openToAll: true },
|
|
115
|
+
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
const dispatcher = createDispatcher(createRegistry([rlFeature]), {});
|
|
120
|
+
const result = await dispatcher.write(
|
|
121
|
+
"rl-user:write:item:create",
|
|
122
|
+
{ name: "should-not-run" },
|
|
123
|
+
createTestUser(),
|
|
124
|
+
);
|
|
125
|
+
expect(result.isSuccess).toBe(false);
|
|
126
|
+
if (!result.isSuccess) {
|
|
127
|
+
expect(result.error.code).toBe("internal_error");
|
|
128
|
+
expect(result.error.message).toMatch(/RateLimitResolver is configured/);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
103
132
|
test("ctx.user ist Convenience-Alias auf event.user (gleicher Wert)", async () => {
|
|
104
133
|
// Pinst dass der Handler auf ctx.user zugreifen kann ohne den
|
|
105
134
|
// typo-resistenten event.user-Pfad zu nutzen. Identitätsprüfung
|
|
@@ -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)`,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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
|
-
});
|