@cosmicdrift/kumiko-framework 0.230.0 → 0.232.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 +3 -3
- package/src/bun-db/query.ts +37 -36
- package/src/compliance/__tests__/sub-processors.test.ts +12 -12
- package/src/compliance/sub-processors.ts +11 -11
- package/src/db/__tests__/blind-index.integration.test.ts +32 -0
- package/src/db/blind-index-cleanup.ts +6 -0
- package/src/db/entity-table-meta.ts +23 -23
- package/src/engine/__tests__/build-app-schema.test.ts +42 -0
- package/src/engine/__tests__/define-workflow.test.ts +23 -1
- package/src/engine/__tests__/steps-workflow.test.ts +160 -5
- package/src/engine/define-workflow.ts +48 -11
- package/src/engine/feature-ui-extensions.ts +6 -3
- package/src/engine/index.ts +2 -0
- package/src/engine/pipeline.ts +34 -7
- package/src/engine/steps/_event-match.ts +98 -0
- package/src/engine/steps/wait-for-event.ts +25 -6
- package/src/files/__tests__/file-handle.test.ts +26 -0
- package/src/files/file-handle.ts +1 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +40 -18
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +41 -1
- package/src/search/purge-subject.ts +5 -0
- package/src/testing/__tests__/wait-for.test.ts +41 -0
- package/src/testing/wait-for.ts +16 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.232.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.232.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.232.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
package/src/bun-db/query.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Typed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Typed query API over Bun.sql. No drizzle internally. Same signature as the
|
|
2
|
+
// legacy `db/query-api.ts` — app-code migration is just an import-path swap,
|
|
3
|
+
// not a call-site refactor.
|
|
4
4
|
//
|
|
5
5
|
// API:
|
|
6
6
|
// selectMany<T>(db, table, where?, opts?) → readonly T[]
|
|
@@ -13,11 +13,11 @@
|
|
|
13
13
|
// deleteManyBatched(db, table, where, { limit }) → { deleted, batches }
|
|
14
14
|
// transaction<T>(db, fn) → T
|
|
15
15
|
//
|
|
16
|
-
// `table`
|
|
16
|
+
// `table` can be either:
|
|
17
17
|
// - EntityTableMeta (preferred, plain data)
|
|
18
|
-
// - drizzle pgTable (legacy,
|
|
19
|
-
// drizzle's getTableName + getTableColumns (drizzle
|
|
20
|
-
// reference,
|
|
18
|
+
// - drizzle pgTable (legacy, has Symbol-based tableName) — extracted via
|
|
19
|
+
// drizzle's getTableName + getTableColumns (drizzle kept only as a type
|
|
20
|
+
// reference, never as a runtime API call)
|
|
21
21
|
|
|
22
22
|
import { KUMIKO_META_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
23
23
|
import type {
|
|
@@ -49,7 +49,7 @@ function snakeToCamel(key: string): string {
|
|
|
49
49
|
import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
|
|
50
50
|
import type { BunDbRunner } from "./connection";
|
|
51
51
|
|
|
52
|
-
// Drizzle
|
|
52
|
+
// Drizzle pgTable inspection via raw Symbol access (no drizzle-orm import).
|
|
53
53
|
// table() (dialect) stores the canonical, shadow-proof EntityTableMeta under
|
|
54
54
|
// `Symbol.for("kumiko:schema:Meta")`. The column handles on the table object are
|
|
55
55
|
// enumerable props, so an entity field named `source`/`columns`/`tableName`/…
|
|
@@ -79,12 +79,12 @@ export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
|
|
|
79
79
|
return isEntityTableMeta(table) ? table : undefined;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// `db`
|
|
83
|
-
// 1. Bun.SQL connection (BunDbRunner) —
|
|
84
|
-
// 2. drizzle DbConnection (postgres-js
|
|
85
|
-
// raw postgres-js client
|
|
86
|
-
// 3. drizzle tx-handle — client
|
|
87
|
-
//
|
|
82
|
+
// `db` input accepts three shapes:
|
|
83
|
+
// 1. Bun.SQL connection (BunDbRunner) — new world, native .unsafe + .begin
|
|
84
|
+
// 2. drizzle DbConnection (postgres-js wrapper) — legacy compat,
|
|
85
|
+
// raw postgres-js client sits under `db.$client.unsafe / .begin`
|
|
86
|
+
// 3. drizzle tx-handle — client sits under `tx.session.client`
|
|
87
|
+
// The shim extracts a `{unsafe, begin}` surface in every case.
|
|
88
88
|
type RawClient = {
|
|
89
89
|
unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
|
|
90
90
|
begin: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>;
|
|
@@ -254,23 +254,24 @@ function isWhereOperator(v: unknown): v is WhereOperator {
|
|
|
254
254
|
if (keys.length === 0) return false;
|
|
255
255
|
return keys.every((k) => (WHERE_OPERATOR_KEYS as readonly string[]).includes(k));
|
|
256
256
|
}
|
|
257
|
-
//
|
|
257
|
+
// Accepts EITHER. Both have a tableName and field→column mapping.
|
|
258
258
|
// biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
|
|
259
259
|
type TableLike = EntityTableMeta | any;
|
|
260
260
|
|
|
261
|
-
// Write
|
|
262
|
-
// EntityTable (managed projection).
|
|
263
|
-
//
|
|
264
|
-
// rebuild-safe). Unmanaged EntityTableMeta
|
|
265
|
-
// TableColumns<any>
|
|
266
|
-
//
|
|
261
|
+
// Write param: everything TableLike accepts EXCEPT a branded
|
|
262
|
+
// EntityTable (managed projection). A direct write to an Entity is a
|
|
263
|
+
// compile error — the only write path is the executor (event ->
|
|
264
|
+
// rebuild-safe). Unmanaged EntityTableMeta plus the table erased to
|
|
265
|
+
// TableColumns<any> at the executor seam don't carry the brand -> allowed.
|
|
266
|
+
// Test seeds go through the sanctioned testing seam (seedRow/…), which
|
|
267
|
+
// strips the brand.
|
|
267
268
|
// Method-form writes (ctx.db.insertOne/…) keep an erased param and are covered
|
|
268
269
|
// by the guard-direct-entity-writes AST guard instead (see tenant-db.ts).
|
|
269
270
|
type WritableTable = EntityTableMeta & NotExecutorOnly;
|
|
270
271
|
|
|
271
272
|
export type TableInfo = {
|
|
272
273
|
readonly name: string;
|
|
273
|
-
// field
|
|
274
|
+
// field name (camelCase or snake_case) → snake_case column name
|
|
274
275
|
readonly columnOf: (field: string) => string;
|
|
275
276
|
// pgType per column-name, for jsonb-cast detection
|
|
276
277
|
readonly pgTypeOf: (column: string) => string | undefined;
|
|
@@ -366,8 +367,8 @@ function coerceBigIntFromDriver(value: bigint | string): bigint | number {
|
|
|
366
367
|
function instantFromDriver(value: unknown): Temporal.Instant | null {
|
|
367
368
|
if (value === null || value === undefined) return null;
|
|
368
369
|
if (isTemporalInstant(value)) return value as Temporal.Instant;
|
|
369
|
-
// Number
|
|
370
|
-
// (guard-no-date-api). Date →
|
|
370
|
+
// Number coercion via +date — equivalent to .getTime() without a Date-API
|
|
371
|
+
// method (guard-no-date-api). Date → epoch milliseconds, then to Temporal.Instant.
|
|
371
372
|
if (value instanceof Date) return Temporal.Instant.fromEpochMilliseconds(+value);
|
|
372
373
|
if (typeof value === "string") {
|
|
373
374
|
try {
|
|
@@ -437,8 +438,8 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
|
|
|
437
438
|
const out: Record<string, unknown> = {};
|
|
438
439
|
let changed = false;
|
|
439
440
|
for (const key of Object.keys(row)) {
|
|
440
|
-
// Blind-
|
|
441
|
-
//
|
|
441
|
+
// Blind-index columns (#818) are pure lookup artifacts — the
|
|
442
|
+
// deterministic HMAC must never leak into caller/API responses.
|
|
442
443
|
if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
|
|
443
444
|
changed = true;
|
|
444
445
|
continue;
|
|
@@ -505,11 +506,11 @@ function coerceRows<T extends Record<string, unknown>>(
|
|
|
505
506
|
return rows.map((r) => coerceRow(r, info));
|
|
506
507
|
}
|
|
507
508
|
|
|
508
|
-
// Helper
|
|
509
|
-
// ::jsonb cast. JSON.stringify
|
|
509
|
+
// Helper for jsonb values: structured values bind as a JS object/array with
|
|
510
|
+
// an ::jsonb cast. JSON.stringify before the cast would produce JSON-string scalars.
|
|
510
511
|
// Plus Temporal.Instant → ISO string coercion for timestamptz columns.
|
|
511
|
-
// SqlExpression (sql`now()`, sql`gen_random_uuid()`)
|
|
512
|
-
//
|
|
512
|
+
// SqlExpression (sql`now()`, sql`gen_random_uuid()`) is returned as
|
|
513
|
+
// kind:"literal" — the caller embeds it inline instead of setting a $N placeholder.
|
|
513
514
|
type PreparedValue =
|
|
514
515
|
| { readonly kind: "param"; readonly sql: string; readonly bound: unknown }
|
|
515
516
|
| { readonly kind: "literal"; readonly literal: string };
|
|
@@ -693,12 +694,12 @@ function buildWhereClause(
|
|
|
693
694
|
if (p.kind === "literal") {
|
|
694
695
|
conditions.push(`${quoteIdent(col)} = ${p.literal}`);
|
|
695
696
|
} else {
|
|
696
|
-
// Blind-
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
697
|
+
// Blind-index OR rewrite (#818): if the column has a bidx
|
|
698
|
+
// counterpart (suffix convention `<col>_bidx`, framework-reserved)
|
|
699
|
+
// and a key is configured, equality matches both arms —
|
|
700
|
+
// plaintext legacy rows via the plaintext arm, encrypted rows
|
|
701
|
+
// via the HMAC. The `kumiko-pii:` prefix on ciphertexts makes
|
|
702
|
+
// a random match on the plaintext arm practically impossible.
|
|
702
703
|
const bidxKey = configuredBlindIndexKey();
|
|
703
704
|
const bidxCol = `${col}_bidx`;
|
|
704
705
|
if (
|
|
@@ -82,16 +82,6 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
|
|
|
82
82
|
"sccRequired": false,
|
|
83
83
|
"status": "active",
|
|
84
84
|
},
|
|
85
|
-
{
|
|
86
|
-
"appliesTo": [
|
|
87
|
-
"all-tiers",
|
|
88
|
-
],
|
|
89
|
-
"name": "Heinlein Hosting (Mailbox.org)",
|
|
90
|
-
"optInOnly": false,
|
|
91
|
-
"region": "EU (Germany)",
|
|
92
|
-
"sccRequired": false,
|
|
93
|
-
"status": "active",
|
|
94
|
-
},
|
|
95
85
|
{
|
|
96
86
|
"appliesTo": [
|
|
97
87
|
"business",
|
|
@@ -101,7 +91,7 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
|
|
|
101
91
|
"optInOnly": true,
|
|
102
92
|
"region": "US",
|
|
103
93
|
"sccRequired": true,
|
|
104
|
-
"status": "
|
|
94
|
+
"status": "active",
|
|
105
95
|
},
|
|
106
96
|
{
|
|
107
97
|
"appliesTo": [
|
|
@@ -111,7 +101,17 @@ describe("KUMIKO_SUB_PROCESSORS", () => {
|
|
|
111
101
|
"optInOnly": false,
|
|
112
102
|
"region": "Global (US-headquartered)",
|
|
113
103
|
"sccRequired": true,
|
|
114
|
-
"status": "
|
|
104
|
+
"status": "active",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"appliesTo": [
|
|
108
|
+
"all-tiers",
|
|
109
|
+
],
|
|
110
|
+
"name": "ALL-INKL.COM – Neue Medien Münnich",
|
|
111
|
+
"optInOnly": false,
|
|
112
|
+
"region": "EU (Germany)",
|
|
113
|
+
"sccRequired": false,
|
|
114
|
+
"status": "active",
|
|
115
115
|
},
|
|
116
116
|
]
|
|
117
117
|
`);
|
|
@@ -101,15 +101,6 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
|
|
|
101
101
|
appliesTo: ["standard", "business", "enterprise"],
|
|
102
102
|
status: "active",
|
|
103
103
|
},
|
|
104
|
-
{
|
|
105
|
-
name: "Heinlein Hosting (Mailbox.org)",
|
|
106
|
-
purpose: "Marketing Email Delivery",
|
|
107
|
-
region: "EU (Germany)",
|
|
108
|
-
dpa: "https://mailbox.org/de/datenschutzerklaerung",
|
|
109
|
-
addedAt: "2024-03-01",
|
|
110
|
-
appliesTo: ["all-tiers"],
|
|
111
|
-
status: "active",
|
|
112
|
-
},
|
|
113
104
|
{
|
|
114
105
|
name: "Anthropic PBC",
|
|
115
106
|
purpose: "AI Model Inference (L2 Composition Layer, AI-Foundation)",
|
|
@@ -120,7 +111,7 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
|
|
|
120
111
|
sccRequired: true,
|
|
121
112
|
optInOnly: true,
|
|
122
113
|
hipaaBaaAvailable: true,
|
|
123
|
-
status: "
|
|
114
|
+
status: "active",
|
|
124
115
|
},
|
|
125
116
|
{
|
|
126
117
|
name: "Stripe, Inc.",
|
|
@@ -130,7 +121,16 @@ export const KUMIKO_SUB_PROCESSORS: readonly SubProcessor[] = [
|
|
|
130
121
|
addedAt: "2026-06-01",
|
|
131
122
|
appliesTo: ["all-tiers"],
|
|
132
123
|
sccRequired: true,
|
|
133
|
-
status: "
|
|
124
|
+
status: "active",
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "ALL-INKL.COM – Neue Medien Münnich",
|
|
128
|
+
purpose: "Mailbox Hosting (support and contact mailboxes)",
|
|
129
|
+
region: "EU (Germany)",
|
|
130
|
+
dpa: "https://all-inkl.com/members/avv_muster_print.php",
|
|
131
|
+
addedAt: "2026-09-02",
|
|
132
|
+
appliesTo: ["all-tiers"],
|
|
133
|
+
status: "active",
|
|
134
134
|
},
|
|
135
135
|
];
|
|
136
136
|
|
|
@@ -45,6 +45,18 @@ const personFeature = defineFeature("bidxtest", (r) => {
|
|
|
45
45
|
});
|
|
46
46
|
const personTable = buildEntityTable("person", personEntity);
|
|
47
47
|
|
|
48
|
+
// Lookupable entity whose read table is deliberately never created — used to
|
|
49
|
+
// prove the forget sweep skips missing relations (fw#2550).
|
|
50
|
+
const ghostEntity = createEntity({
|
|
51
|
+
table: "read_bidx_ghosts_missing",
|
|
52
|
+
fields: {
|
|
53
|
+
email: createTextField({ required: true, personal: "self", find: "exact" }),
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const ghostFeature = defineFeature("bidxghost", (r) => {
|
|
57
|
+
r.entity("ghost", ghostEntity);
|
|
58
|
+
});
|
|
59
|
+
|
|
48
60
|
let testDb: TestDb;
|
|
49
61
|
let tdb: TenantDb;
|
|
50
62
|
let kms: InMemoryKmsAdapter;
|
|
@@ -247,4 +259,24 @@ describe("blind-index rebuild + forget", () => {
|
|
|
247
259
|
expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
|
|
248
260
|
expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
|
|
249
261
|
});
|
|
262
|
+
|
|
263
|
+
test("nullBlindIndexesForSubject skips missing projection tables (fw#2550)", async () => {
|
|
264
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
265
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
266
|
+
expect((await rawRow(String(created.data.id)))["email_bidx"]).not.toBeNull();
|
|
267
|
+
|
|
268
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
269
|
+
// Registry includes a lookupable ghost whose table was never migrated —
|
|
270
|
+
// pre-fix this UPDATE threw after eraseKey and left bidx linkable.
|
|
271
|
+
const registry = createRegistry([personFeature, ghostFeature]);
|
|
272
|
+
await expect(
|
|
273
|
+
nullBlindIndexesForSubject(
|
|
274
|
+
testDb.db,
|
|
275
|
+
registry.features,
|
|
276
|
+
subjectIdToKey({ kind: "user", userId: String(created.data.id) }),
|
|
277
|
+
),
|
|
278
|
+
).resolves.toBeUndefined();
|
|
279
|
+
|
|
280
|
+
expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
|
|
281
|
+
});
|
|
250
282
|
});
|
|
@@ -19,6 +19,7 @@ import { toSnakeCase } from "../utils/case";
|
|
|
19
19
|
import type { DbRunner } from "./connection";
|
|
20
20
|
import { resolveTableName } from "./entity-table-meta";
|
|
21
21
|
import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
|
|
22
|
+
import { tableExists } from "./schema-inspection";
|
|
22
23
|
|
|
23
24
|
export async function nullBlindIndexesForSubject(
|
|
24
25
|
db: DbRunner,
|
|
@@ -34,6 +35,11 @@ export async function nullBlindIndexesForSubject(
|
|
|
34
35
|
// one (buildEntityTable with no featureName option), the sweep has to
|
|
35
36
|
// hit the same names.
|
|
36
37
|
const tableName = resolveTableName(entityName, entity, undefined);
|
|
38
|
+
// Skip entities whose projection was never migrated — same class as
|
|
39
|
+
// subjectRowExistsInTenant (fw#2348). A throw here runs AFTER eraseKey
|
|
40
|
+
// in forget-subject and would leave deterministic bidx columns still
|
|
41
|
+
// linkable (fw#2550).
|
|
42
|
+
if (!(await tableExists(db, tableName))) continue;
|
|
37
43
|
for (const fieldName of lookupable) {
|
|
38
44
|
const snake = toSnakeCase(fieldName);
|
|
39
45
|
await executeRawQuery(
|
|
@@ -38,8 +38,8 @@ export type {
|
|
|
38
38
|
UnmanagedTableInput,
|
|
39
39
|
} from "./entity-table-meta-types";
|
|
40
40
|
|
|
41
|
-
// Standard base
|
|
42
|
-
// `buildBaseColumns()`
|
|
41
|
+
// Standard base columns for event-sourced read-model tables. Mirrors
|
|
42
|
+
// `buildBaseColumns()` from table-builder.ts (drizzle variant).
|
|
43
43
|
function fullBaseColumns(idType: "uuid" | "serial", softDelete: boolean): readonly ColumnMeta[] {
|
|
44
44
|
const idCol: ColumnMeta =
|
|
45
45
|
idType === "uuid"
|
|
@@ -85,8 +85,8 @@ function fieldDefaultLiteral(field: FieldDefinition): string | undefined {
|
|
|
85
85
|
return undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
//
|
|
89
|
-
// Lock-step:
|
|
88
|
+
// Mirrors `fieldToColumns()` from table-builder.ts (drizzle variant).
|
|
89
|
+
// Lock-step: every field type must produce the same PG columns as today.
|
|
90
90
|
function fieldToColumnMeta(
|
|
91
91
|
name: string,
|
|
92
92
|
field: FieldDefinition,
|
|
@@ -105,9 +105,9 @@ function fieldToColumnMeta(
|
|
|
105
105
|
...(def !== undefined && { defaultSql: def }),
|
|
106
106
|
},
|
|
107
107
|
];
|
|
108
|
-
// lookupable → HMAC-
|
|
109
|
-
// managedChangeRequiresRecreate
|
|
110
|
-
//
|
|
108
|
+
// lookupable → HMAC blind-index counterpart. Nullable is mandatory:
|
|
109
|
+
// managedChangeRequiresRecreate would diff a NOT-NULL column without
|
|
110
|
+
// a default on existing tables as DROP+rebuild.
|
|
111
111
|
if (field.type === "text" && field.lookupable === true) {
|
|
112
112
|
cols.push({ name: `${snake}_bidx`, pgType: "text", notNull: false });
|
|
113
113
|
}
|
|
@@ -262,13 +262,13 @@ export function deriveEntityTableMeta(
|
|
|
262
262
|
}
|
|
263
263
|
const idType = entity.idType ?? "uuid";
|
|
264
264
|
|
|
265
|
-
// Base
|
|
266
|
-
// pg-name as a base-column OVERRIDE the base-column (last-wins,
|
|
267
|
-
//
|
|
268
|
-
// builder). Use
|
|
269
|
-
// control
|
|
270
|
-
// filterable
|
|
271
|
-
//
|
|
265
|
+
// Base columns first, then user-fields. User-fields with the same
|
|
266
|
+
// pg-name as a base-column OVERRIDE the base-column (last-wins, same
|
|
267
|
+
// behavior as drizzle's `{ ...base, ...fields }` spread in the table
|
|
268
|
+
// builder). Use case: user-session has `tenantId` as a field to enforce
|
|
269
|
+
// access control, fileRef has `insertedAt` as a field for sortable/
|
|
270
|
+
// filterable marking. The DB column stays the same, only the application
|
|
271
|
+
// metadata on the field side changes.
|
|
272
272
|
const baseCols = fullBaseColumns(idType, entity.softDelete === true);
|
|
273
273
|
const colByName = new Map<string, ColumnMeta>();
|
|
274
274
|
for (const c of baseCols) colByName.set(c.name, c);
|
|
@@ -323,15 +323,15 @@ export function deriveEntityTableMeta(
|
|
|
323
323
|
indexes.push({ name: `${tableName}_${snake}_idx`, columns: [snake] });
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
// lookupable
|
|
327
|
-
//
|
|
326
|
+
// lookupable fields: index on the bidx column — the query compiler's
|
|
327
|
+
// OR rewrite hits it on every equality lookup.
|
|
328
328
|
for (const bidxSnake of bidxSnakeByFieldSnake.values()) {
|
|
329
329
|
indexes.push({ name: `${tableName}_${bidxSnake}_idx`, columns: [bidxSnake] });
|
|
330
330
|
}
|
|
331
331
|
|
|
332
|
-
//
|
|
333
|
-
// SqlExpression (`sql\`…\``
|
|
334
|
-
//
|
|
332
|
+
// Explicitly declared indexes (EntityIndexDef). `def.where` is a
|
|
333
|
+
// SqlExpression (`sql\`…\`` from @cosmicdrift/kumiko-framework/db) —
|
|
334
|
+
// renderable via `.text`. Unknown where-shapes stay needsManualWhere.
|
|
335
335
|
for (const def of (entity.indexes ?? []) as readonly EntityIndexDef[]) {
|
|
336
336
|
const cols = def.columns.map(
|
|
337
337
|
(fieldName) => fieldNameToSnake.get(fieldName) ?? toSnakeCase(fieldName),
|
|
@@ -346,10 +346,10 @@ export function deriveEntityTableMeta(
|
|
|
346
346
|
...(whereSql !== undefined && { whereSql }),
|
|
347
347
|
...(def.where !== undefined && whereSql === undefined && { needsManualWhere: true }),
|
|
348
348
|
});
|
|
349
|
-
// Unique
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
// key-
|
|
349
|
+
// Unique index over lookupable columns: partial bidx counterpart, so
|
|
350
|
+
// uniqueness also holds for encrypted rows. The original stays
|
|
351
|
+
// for plaintext legacy rows; partial (IS NOT NULL) because bidx is
|
|
352
|
+
// NULL for erased/key-less rows.
|
|
353
353
|
if (def.unique === true && def.where === undefined) {
|
|
354
354
|
const bidxCols = cols.map((c) => bidxSnakeByFieldSnake.get(c) ?? c);
|
|
355
355
|
if (bidxCols.some((c, i) => c !== cols[i])) {
|
|
@@ -49,6 +49,48 @@ describe("buildAppSchema", () => {
|
|
|
49
49
|
expect(fleet?.entities["vehicle"]).toBeDefined();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
// r.screen({ nav: {...} }) sugar must omit absent optional nav fields
|
|
53
|
+
// rather than writing them as explicit `undefined` — a plain key-per-
|
|
54
|
+
// field object literal makes `undefined` a real value in the schema,
|
|
55
|
+
// which findNonJsonSafePath flags as a boot-time warning even though
|
|
56
|
+
// JSON.stringify silently drops the key later. r.contentCollection()
|
|
57
|
+
// already gets this right via a conditional spread; this pins the same
|
|
58
|
+
// fix for r.screen().
|
|
59
|
+
test("r.screen({ nav }) sugar omits absent optional nav fields instead of writing them as undefined", () => {
|
|
60
|
+
const topLevelFeature = defineFeature("privacy", (r) => {
|
|
61
|
+
r.screen({
|
|
62
|
+
id: "privacy-center",
|
|
63
|
+
type: "custom",
|
|
64
|
+
renderer: { react: { __component: "PrivacyCenterScreen" } },
|
|
65
|
+
nav: { label: "Privacy" },
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
const nestedFeature = defineFeature("billing", (r) => {
|
|
69
|
+
r.screen({
|
|
70
|
+
id: "invoices",
|
|
71
|
+
type: "custom",
|
|
72
|
+
renderer: { react: { __component: "InvoicesScreen" } },
|
|
73
|
+
nav: { label: "Invoices", parent: "billing:nav:overview" },
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const app = buildAppSchema(createRegistry([topLevelFeature, nestedFeature]));
|
|
78
|
+
|
|
79
|
+
const privacyFeature = app.features.find((f) => f.featureName === "privacy");
|
|
80
|
+
const topLevelNav = privacyFeature?.navs?.[0];
|
|
81
|
+
if (!topLevelNav) throw new Error("privacy feature's nav entry is missing");
|
|
82
|
+
expect("parent" in topLevelNav).toBe(false);
|
|
83
|
+
expect("icon" in topLevelNav).toBe(false);
|
|
84
|
+
expect("order" in topLevelNav).toBe(false);
|
|
85
|
+
|
|
86
|
+
const billingFeature = app.features.find((f) => f.featureName === "billing");
|
|
87
|
+
const nestedNav = billingFeature?.navs?.[0];
|
|
88
|
+
if (!nestedNav) throw new Error("billing feature's nav entry is missing");
|
|
89
|
+
expect(nestedNav.parent).toBe("billing:nav:overview");
|
|
90
|
+
|
|
91
|
+
expect(findNonJsonSafePath(app, "schema")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
52
94
|
// #1059: r.translations({keys}) must flow into FeatureSchema.translations
|
|
53
95
|
// BYTE-IDENTICAL — nav/screen labels resolve these keys verbatim via
|
|
54
96
|
// t(label) client-side, so any re-prefixing (like the registry's internal
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
defineWorkflow,
|
|
5
5
|
type WorkflowTrigger,
|
|
6
6
|
} from "../define-workflow";
|
|
7
|
+
import { stepsPipeline } from "../pipeline";
|
|
7
8
|
import type { PipelineDef } from "../types/step";
|
|
8
9
|
|
|
9
10
|
const pipe = (build: PipelineDef["build"]): PipelineDef => ({ __kind: "pipeline", build });
|
|
@@ -17,7 +18,28 @@ describe("defineWorkflow", () => {
|
|
|
17
18
|
expect(wf.__kind).toBe("workflow");
|
|
18
19
|
expect(wf.name).toBe("onboard");
|
|
19
20
|
expect(wf.trigger).toEqual(eventTrigger);
|
|
20
|
-
|
|
21
|
+
// pipelineDef is no longer `steps` by reference — defineWorkflow rebuilds
|
|
22
|
+
// it to carry the (possibly absent) branded awaits map — but the
|
|
23
|
+
// closure itself must be the exact same function.
|
|
24
|
+
expect(wf.pipelineDef.__kind).toBe("pipeline");
|
|
25
|
+
expect(wf.pipelineDef.build).toBe(steps.build);
|
|
26
|
+
expect(wf.awaits).toBeUndefined();
|
|
27
|
+
expect(wf.pipelineDef.awaits).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("brands the declared awaits map onto both the definition and the pipelineDef", () => {
|
|
31
|
+
const wf = defineWorkflow({
|
|
32
|
+
name: "order-fulfillment",
|
|
33
|
+
trigger: eventTrigger,
|
|
34
|
+
awaits: { shipped: "order.shipped", cancelled: "order.cancelled" },
|
|
35
|
+
steps: stepsPipeline(() => []),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(wf.awaits).toEqual({ shipped: "order.shipped", cancelled: "order.cancelled" });
|
|
39
|
+
expect(wf.pipelineDef.awaits).toEqual({
|
|
40
|
+
shipped: "order.shipped",
|
|
41
|
+
cancelled: "order.cancelled",
|
|
42
|
+
});
|
|
21
43
|
});
|
|
22
44
|
});
|
|
23
45
|
|