@cosmicdrift/kumiko-framework 0.105.2 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
|
@@ -109,7 +109,7 @@ function scalarDefault(field: FieldDefinition): unknown {
|
|
|
109
109
|
// to entity creates/updates/etc should reference this helper instead of
|
|
110
110
|
// hardcoding the string — a future rename in the executor then surfaces
|
|
111
111
|
// as a type error at every call site rather than a silent miss.
|
|
112
|
-
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored";
|
|
112
|
+
export type EntityLifecycleVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
|
|
113
113
|
|
|
114
114
|
export function entityEventName(entityName: string, verb: EntityLifecycleVerb): string {
|
|
115
115
|
return `${entityName}.${verb}`;
|
|
@@ -187,6 +187,16 @@ export type EventStoreExecutor = {
|
|
|
187
187
|
db: TenantDb,
|
|
188
188
|
) => Promise<WriteResult<DeleteContext>>;
|
|
189
189
|
|
|
190
|
+
// Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
|
|
191
|
+
// which hard-deletes the row even for softDelete entities — and, being an
|
|
192
|
+
// auto-verb replayed by the implicit projection, the erasure survives a
|
|
193
|
+
// rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
|
|
194
|
+
forget: (
|
|
195
|
+
payload: { id: EntityId },
|
|
196
|
+
user: SessionUser,
|
|
197
|
+
db: TenantDb,
|
|
198
|
+
) => Promise<WriteResult<DeleteContext>>;
|
|
199
|
+
|
|
190
200
|
restore: (
|
|
191
201
|
payload: { id: EntityId },
|
|
192
202
|
user: SessionUser,
|
|
@@ -645,7 +655,10 @@ export function createEventStoreExecutor(
|
|
|
645
655
|
// you'd have to snapshot-and-diff on every apply, and replays would
|
|
646
656
|
// break. Storage cost is acceptable (rows are bounded), correctness is
|
|
647
657
|
// not negotiable. Sensitive fields are stripped from BOTH halves so
|
|
648
|
-
// they never reach the immutable event log.
|
|
658
|
+
// they never reach the immutable event log. `previous` came from
|
|
659
|
+
// loadById(), which decrypts — re-encrypt it before it's persisted so
|
|
660
|
+
// an `encrypted` field's plaintext doesn't land in the immutable log
|
|
661
|
+
// (flatChanges is already ciphertext from encryptForStorage above).
|
|
649
662
|
const event = await append(db.raw, {
|
|
650
663
|
aggregateId: String(payload.id),
|
|
651
664
|
aggregateType: entityName,
|
|
@@ -654,7 +667,7 @@ export function createEventStoreExecutor(
|
|
|
654
667
|
type: entityEventName(entityName, "updated"),
|
|
655
668
|
payload: {
|
|
656
669
|
changes: stripSensitive(flatChanges),
|
|
657
|
-
previous: stripSensitive(previous),
|
|
670
|
+
previous: stripSensitive(encryptForStorage(previous)),
|
|
658
671
|
},
|
|
659
672
|
metadata: buildEventMetadata(user),
|
|
660
673
|
});
|
|
@@ -755,13 +768,16 @@ export function createEventStoreExecutor(
|
|
|
755
768
|
// projections and downstream consumers need to reverse any aggregates —
|
|
756
769
|
// a `{}`-payload delete would make cross-aggregate projections impossible
|
|
757
770
|
// to rebuild from the event log alone. Sensitive fields are stripped.
|
|
771
|
+
// `existing` came from loadById(), which decrypts — re-encrypt before
|
|
772
|
+
// persisting so an `encrypted` field's plaintext doesn't land in the
|
|
773
|
+
// immutable log.
|
|
758
774
|
const event = await append(db.raw, {
|
|
759
775
|
aggregateId: String(payload.id),
|
|
760
776
|
aggregateType: entityName,
|
|
761
777
|
tenantId: streamTenantFor(user),
|
|
762
778
|
expectedVersion: currentVersion,
|
|
763
779
|
type: entityEventName(entityName, "deleted"),
|
|
764
|
-
payload: { previous: stripSensitive(existing) },
|
|
780
|
+
payload: { previous: stripSensitive(encryptForStorage(existing)) },
|
|
765
781
|
metadata: buildEventMetadata(user),
|
|
766
782
|
});
|
|
767
783
|
|
|
@@ -787,6 +803,64 @@ export function createEventStoreExecutor(
|
|
|
787
803
|
};
|
|
788
804
|
},
|
|
789
805
|
|
|
806
|
+
// Hard-purge (Art. 17). Same shape as delete(), but emits `forgotten` which
|
|
807
|
+
// hard-deletes the row regardless of softDelete — and, being an auto-verb,
|
|
808
|
+
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
809
|
+
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
810
|
+
async forget(payload, user, db) {
|
|
811
|
+
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
812
|
+
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
813
|
+
const existing = decryptForRead(rehydrateCompoundTypes(raw as DbRow, entity) as DbRow);
|
|
814
|
+
|
|
815
|
+
if (!userCanWriteFieldRow(user, entity.access?.write, existing, existing)) {
|
|
816
|
+
return writeFailure(
|
|
817
|
+
new UnprocessableError("ownership_denied", {
|
|
818
|
+
i18nKey: "errors.ownershipDenied",
|
|
819
|
+
details: {
|
|
820
|
+
scope: "entity",
|
|
821
|
+
entityName,
|
|
822
|
+
action: "delete",
|
|
823
|
+
userId: user.id,
|
|
824
|
+
entityId: payload.id,
|
|
825
|
+
},
|
|
826
|
+
}),
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
831
|
+
const currentVersion = await getStreamVersion(
|
|
832
|
+
db.raw,
|
|
833
|
+
String(payload.id),
|
|
834
|
+
streamTenantFor(user),
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
const event = await append(db.raw, {
|
|
838
|
+
aggregateId: String(payload.id),
|
|
839
|
+
aggregateType: entityName,
|
|
840
|
+
tenantId: streamTenantFor(user),
|
|
841
|
+
expectedVersion: currentVersion,
|
|
842
|
+
type: entityEventName(entityName, "forgotten"),
|
|
843
|
+
payload: { previous: stripSensitive(existing) },
|
|
844
|
+
metadata: buildEventMetadata(user),
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
|
|
848
|
+
if (forgetResult.kind !== "applied") {
|
|
849
|
+
return writeFailure(
|
|
850
|
+
new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (entityCache && entityName) {
|
|
855
|
+
await entityCache.del(user.tenantId, entityName, payload.id);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
return {
|
|
859
|
+
isSuccess: true,
|
|
860
|
+
data: { kind: "delete", id: payload.id, data: existing, entityName, event },
|
|
861
|
+
};
|
|
862
|
+
},
|
|
863
|
+
|
|
790
864
|
async restore(payload, user, db) {
|
|
791
865
|
if (!softDelete) {
|
|
792
866
|
return writeFailure(
|
|
@@ -859,7 +933,13 @@ export function createEventStoreExecutor(
|
|
|
859
933
|
}
|
|
860
934
|
|
|
861
935
|
// Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
|
|
862
|
-
|
|
936
|
+
// decryptForRead matches create/update/list/detail: the caller-facing
|
|
937
|
+
// row and `previous` snapshot must be plaintext for `encrypted` fields,
|
|
938
|
+
// same as every other executor method — `data`/`restored` are raw rows
|
|
939
|
+
// (selectMany / applyEntityEvent), never decrypted before this point.
|
|
940
|
+
const restoredHydrated = decryptForRead(
|
|
941
|
+
rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
|
|
942
|
+
);
|
|
863
943
|
|
|
864
944
|
return {
|
|
865
945
|
isSuccess: true,
|
|
@@ -868,7 +948,7 @@ export function createEventStoreExecutor(
|
|
|
868
948
|
id: payload.id,
|
|
869
949
|
data: restoredHydrated,
|
|
870
950
|
changes: { isDeleted: false },
|
|
871
|
-
previous: data,
|
|
951
|
+
previous: decryptForRead(data),
|
|
872
952
|
isNew: false,
|
|
873
953
|
entityName,
|
|
874
954
|
event,
|
|
@@ -1073,7 +1153,10 @@ export function createEventStoreExecutor(
|
|
|
1073
1153
|
const checkRows = await loadWithOwnership(db, idWhere, ownership);
|
|
1074
1154
|
if (checkRows.length === 0) return null;
|
|
1075
1155
|
}
|
|
1076
|
-
|
|
1156
|
+
// Cached rows are stored re-encrypted (see the `set` below) so an
|
|
1157
|
+
// `encrypted` field's plaintext never sits in a second at-rest
|
|
1158
|
+
// store (Redis) the field-encryption feature doesn't cover.
|
|
1159
|
+
return withStreamVersion(decryptForRead(cached));
|
|
1077
1160
|
}
|
|
1078
1161
|
}
|
|
1079
1162
|
|
|
@@ -1085,7 +1168,7 @@ export function createEventStoreExecutor(
|
|
|
1085
1168
|
const coerced = coerceRow(row, rowInfo);
|
|
1086
1169
|
|
|
1087
1170
|
if (entityCache && entityName) {
|
|
1088
|
-
await entityCache.set(user.tenantId, entityName, payload.id, coerced);
|
|
1171
|
+
await entityCache.set(user.tenantId, entityName, payload.id, encryptForStorage(coerced));
|
|
1089
1172
|
}
|
|
1090
1173
|
|
|
1091
1174
|
return withStreamVersion(coerced);
|
|
@@ -104,7 +104,7 @@ export async function fenceLiveTable(
|
|
|
104
104
|
// Postgres treats lock_timeout = 0 as "no timeout" (wait forever) — the
|
|
105
105
|
// opposite of fail-fast. Reject it so a 0/negative value can't silently
|
|
106
106
|
// turn the fence into an unbounded wait.
|
|
107
|
-
if (lockTimeoutMs <= 0) {
|
|
107
|
+
if (Math.trunc(lockTimeoutMs) <= 0) {
|
|
108
108
|
throw new Error(`fenceLockTimeoutMs must be > 0, got ${lockTimeoutMs}`);
|
|
109
109
|
}
|
|
110
110
|
const raw = asRawClient(tx);
|
package/src/db/query.ts
CHANGED
package/src/db/table-builder.ts
CHANGED
|
@@ -365,12 +365,34 @@ type SoftDeleteColumnsType = {
|
|
|
365
365
|
readonly deletedById: NullCol<string>;
|
|
366
366
|
};
|
|
367
367
|
|
|
368
|
+
// ── ES-write brand ──────────────────────────────────────────────────────
|
|
369
|
+
// A managed projection is only writable through the executor (event →
|
|
370
|
+
// rebuild-safe). To make a direct write a *compile* error rather than a
|
|
371
|
+
// convention, EntityTable carries a phantom `unique symbol` prop; the public
|
|
372
|
+
// write helpers reject anything that has it (see NotExecutorOnly + query.ts).
|
|
373
|
+
// The symbol key dodges SchemaTable's `[field: string]: unknown` index
|
|
374
|
+
// signature (string index sigs don't cover symbol keys), so the brand
|
|
375
|
+
// survives the type-erasure that would swallow a plain marker prop — and the
|
|
376
|
+
// executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
|
|
377
|
+
// carries no such prop, so the one legitimate writer stays green.
|
|
378
|
+
declare const EXECUTOR_ONLY: unique symbol;
|
|
379
|
+
export interface ExecutorOnly {
|
|
380
|
+
readonly [EXECUTOR_ONLY]: true;
|
|
381
|
+
}
|
|
382
|
+
// Negative brand for write-helper params: a branded EntityTable is NOT
|
|
383
|
+
// assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
|
|
384
|
+
// and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
|
|
385
|
+
export type NotExecutorOnly = {
|
|
386
|
+
readonly [EXECUTOR_ONLY]?: never;
|
|
387
|
+
};
|
|
388
|
+
|
|
368
389
|
export type EntityTable<E extends EntityDefinition = EntityDefinition> =
|
|
369
390
|
TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
|
|
370
391
|
any> &
|
|
371
392
|
BaseColumnsType<E> &
|
|
372
393
|
SoftDeleteColumnsType &
|
|
373
|
-
ColumnsForEntity<E["fields"]
|
|
394
|
+
ColumnsForEntity<E["fields"]> &
|
|
395
|
+
ExecutorOnly;
|
|
374
396
|
|
|
375
397
|
export function buildBaseColumns(softDelete: boolean, idType: "serial" | "uuid" = "uuid") {
|
|
376
398
|
const idColumn =
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type TenantDb = {
|
|
|
53
53
|
set: Record<string, unknown>,
|
|
54
54
|
where: WhereObject,
|
|
55
55
|
): Promise<readonly T[]>;
|
|
56
|
+
// Method-form writes (ctx.db.insertOne/updateMany/deleteMany) keep the erased
|
|
57
|
+
// Table param: the executor-only brand is enforced on the free-function
|
|
58
|
+
// helpers (the reflexive path all production writes take), while method-form
|
|
59
|
+
// on a branded table is covered by the guard-direct-entity-writes AST guard
|
|
60
|
+
// (P5) — branding it here would force every push+write test into casts for
|
|
61
|
+
// zero production benefit (no prod code writes a projection via method-form).
|
|
56
62
|
deleteMany(table: Table, where: WhereObject): Promise<void>;
|
|
57
63
|
};
|
|
58
64
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
validateGdprStoragePersistence,
|
|
11
11
|
} from "../boot-validator/gdpr-storage";
|
|
12
12
|
import { defineFeature } from "../define-feature";
|
|
13
|
+
import { EXT_USER_DATA } from "../extension-names";
|
|
13
14
|
|
|
14
15
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
15
16
|
const fileProvider = (name: string) =>
|
|
@@ -94,7 +95,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
|
|
|
94
95
|
|
|
95
96
|
test("export + delete hooks → no warn", () => {
|
|
96
97
|
const f = defineFeature("my-feature", (r) => {
|
|
97
|
-
r.useExtension(
|
|
98
|
+
r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
|
|
98
99
|
});
|
|
99
100
|
validateGdprHookCompleteness([f]);
|
|
100
101
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
@@ -102,7 +103,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
|
|
|
102
103
|
|
|
103
104
|
test("export hook without delete hook → Art.17 warn", () => {
|
|
104
105
|
const f = defineFeature("my-feature", (r) => {
|
|
105
|
-
r.useExtension(
|
|
106
|
+
r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
|
|
106
107
|
});
|
|
107
108
|
validateGdprHookCompleteness([f]);
|
|
108
109
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
@@ -114,7 +115,7 @@ describe("validateGdprHookCompleteness (V2)", () => {
|
|
|
114
115
|
|
|
115
116
|
test("delete hook only (no export) → no warn", () => {
|
|
116
117
|
const f = defineFeature("my-feature", (r) => {
|
|
117
|
-
r.useExtension(
|
|
118
|
+
r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
|
|
118
119
|
});
|
|
119
120
|
validateGdprHookCompleteness([f]);
|
|
120
121
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
@@ -130,10 +131,10 @@ describe("validateGdprHookCompleteness (V2)", () => {
|
|
|
130
131
|
|
|
131
132
|
test("multiple features, one missing delete → one warn per missing hook", () => {
|
|
132
133
|
const good = defineFeature("good", (r) => {
|
|
133
|
-
r.useExtension(
|
|
134
|
+
r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
|
|
134
135
|
});
|
|
135
136
|
const bad = defineFeature("bad", (r) => {
|
|
136
|
-
r.useExtension(
|
|
137
|
+
r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
|
|
137
138
|
});
|
|
138
139
|
validateGdprHookCompleteness([good, bad]);
|
|
139
140
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
@@ -5,6 +5,7 @@ import { table, text } from "../../db/dialect";
|
|
|
5
5
|
import { validateBoot } from "../boot-validator";
|
|
6
6
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
7
7
|
import {
|
|
8
|
+
createDerivedField,
|
|
8
9
|
createEntity,
|
|
9
10
|
createMultiSelectField,
|
|
10
11
|
createTextField,
|
|
@@ -168,6 +169,56 @@ describe("boot-validator", () => {
|
|
|
168
169
|
expect(() => validateBoot(features)).toThrow(/ENCRYPTION_KEY.*required/i);
|
|
169
170
|
});
|
|
170
171
|
|
|
172
|
+
test("rejects encrypted text field that is also filterable", () => {
|
|
173
|
+
// AES-GCM draws a random IV per call, so the ciphertext is non-
|
|
174
|
+
// deterministic — an equality filter on it would never match, even
|
|
175
|
+
// against the identical plaintext.
|
|
176
|
+
process.env["ENCRYPTION_KEY"] = "test-key";
|
|
177
|
+
try {
|
|
178
|
+
const features = [
|
|
179
|
+
defineFeature("a", (r) => {
|
|
180
|
+
r.entity(
|
|
181
|
+
"doc",
|
|
182
|
+
createEntity({
|
|
183
|
+
table: "Docs",
|
|
184
|
+
fields: {
|
|
185
|
+
secret: { type: "text", encrypted: true, filterable: true },
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
expect(() => validateBoot(features)).toThrow(/secret.*encrypted and filterable/i);
|
|
192
|
+
} finally {
|
|
193
|
+
delete process.env["ENCRYPTION_KEY"];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("rejects a unique index on an encrypted column", () => {
|
|
198
|
+
// Same non-determinism as the filterable case: a unique index can never
|
|
199
|
+
// detect a real duplicate plaintext behind two different ciphertexts.
|
|
200
|
+
process.env["ENCRYPTION_KEY"] = "test-key";
|
|
201
|
+
try {
|
|
202
|
+
const features = [
|
|
203
|
+
defineFeature("a", (r) => {
|
|
204
|
+
r.entity(
|
|
205
|
+
"doc",
|
|
206
|
+
createEntity({
|
|
207
|
+
table: "Docs",
|
|
208
|
+
fields: {
|
|
209
|
+
secret: { type: "text", encrypted: true },
|
|
210
|
+
},
|
|
211
|
+
indexes: [{ columns: ["secret"], unique: true }],
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}),
|
|
215
|
+
];
|
|
216
|
+
expect(() => validateBoot(features)).toThrow(/secret.*encrypted.*unique index/i);
|
|
217
|
+
} finally {
|
|
218
|
+
delete process.env["ENCRYPTION_KEY"];
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
171
222
|
// --- index-validator longText block ---
|
|
172
223
|
|
|
173
224
|
test("rejects longText field in entity.indexes (longText is not indexable)", () => {
|
|
@@ -309,6 +360,46 @@ describe("boot-validator", () => {
|
|
|
309
360
|
);
|
|
310
361
|
});
|
|
311
362
|
|
|
363
|
+
// --- derivedFields / fields collision ---
|
|
364
|
+
|
|
365
|
+
test("throws when a derivedFields key conflicts with a stored field", () => {
|
|
366
|
+
const features = [
|
|
367
|
+
defineFeature("a", (r) => {
|
|
368
|
+
r.entity(
|
|
369
|
+
"item",
|
|
370
|
+
createEntity({
|
|
371
|
+
table: "Items",
|
|
372
|
+
fields: { status: createTextField() },
|
|
373
|
+
derivedFields: {
|
|
374
|
+
status: createDerivedField({ valueType: "text", derive: () => "computed" }),
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
}),
|
|
379
|
+
];
|
|
380
|
+
expect(() => validateBoot(features)).toThrow(
|
|
381
|
+
/entity "item".*derivedFields key "status".*conflicts/i,
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("passes when derivedFields keys don't conflict with stored fields", () => {
|
|
386
|
+
const features = [
|
|
387
|
+
defineFeature("a", (r) => {
|
|
388
|
+
r.entity(
|
|
389
|
+
"item",
|
|
390
|
+
createEntity({
|
|
391
|
+
table: "Items",
|
|
392
|
+
fields: { status: createTextField() },
|
|
393
|
+
derivedFields: {
|
|
394
|
+
statusLabel: createDerivedField({ valueType: "text", derive: () => "computed" }),
|
|
395
|
+
},
|
|
396
|
+
}),
|
|
397
|
+
);
|
|
398
|
+
}),
|
|
399
|
+
];
|
|
400
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
401
|
+
});
|
|
402
|
+
|
|
312
403
|
// --- Config key cross-feature references ---
|
|
313
404
|
|
|
314
405
|
test("throws when readsConfig references non-existent key", () => {
|
|
@@ -1195,6 +1286,13 @@ describe("boot-validator", () => {
|
|
|
1195
1286
|
]),
|
|
1196
1287
|
).toThrow(/tags/);
|
|
1197
1288
|
});
|
|
1289
|
+
|
|
1290
|
+
test("labeled column on a non-field WITHOUT a renderer → Throw (label alone isn't enough)", () => {
|
|
1291
|
+
// Regression (697/1): renderer is what actually draws a virtual column —
|
|
1292
|
+
// a label with no renderer would push an empty, unrendered column into
|
|
1293
|
+
// the view model instead of a real typo-guard.
|
|
1294
|
+
expect(() => validateBoot([noteFeature({ field: "tags", label: "Tags" })])).toThrow(/tags/);
|
|
1295
|
+
});
|
|
1198
1296
|
});
|
|
1199
1297
|
|
|
1200
1298
|
// --- entityList: pagination + sort validation ---
|
|
@@ -1981,6 +2079,118 @@ describe("boot-validator", () => {
|
|
|
1981
2079
|
});
|
|
1982
2080
|
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
1983
2081
|
});
|
|
2082
|
+
|
|
2083
|
+
test("cross-feature navigate to an entityEdit screen WITHOUT entityId → Throw", () => {
|
|
2084
|
+
// Regression: the renderer's same-feature row["id"] fallback never
|
|
2085
|
+
// fires for a cross-feature target, so the edit screen would silently
|
|
2086
|
+
// open with no entity context at runtime. The validator must catch it
|
|
2087
|
+
// at boot instead.
|
|
2088
|
+
const list = defineFeature("shop", (r) => {
|
|
2089
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2090
|
+
r.screen({
|
|
2091
|
+
id: "product-list",
|
|
2092
|
+
type: "entityList",
|
|
2093
|
+
entity: "product",
|
|
2094
|
+
columns: ["name"],
|
|
2095
|
+
rowActions: [
|
|
2096
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "invoice-edit" },
|
|
2097
|
+
],
|
|
2098
|
+
});
|
|
2099
|
+
});
|
|
2100
|
+
const consumer = defineFeature("billing", (r) => {
|
|
2101
|
+
r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
|
|
2102
|
+
r.screen({
|
|
2103
|
+
id: "invoice-edit",
|
|
2104
|
+
type: "entityEdit",
|
|
2105
|
+
entity: "invoice",
|
|
2106
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
2107
|
+
});
|
|
2108
|
+
});
|
|
2109
|
+
expect(() => validateBoot([list, consumer])).toThrow(
|
|
2110
|
+
/rowAction "edit".*cross-feature to entityEdit screen "invoice-edit".*without an explicit entityId/,
|
|
2111
|
+
);
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
test("cross-feature navigate to an entityEdit screen WITH entityId → kein Throw", () => {
|
|
2115
|
+
const list = defineFeature("shop", (r) => {
|
|
2116
|
+
r.entity(
|
|
2117
|
+
"product",
|
|
2118
|
+
createEntity({ fields: { name: createTextField(), invoiceId: createTextField() } }),
|
|
2119
|
+
);
|
|
2120
|
+
r.screen({
|
|
2121
|
+
id: "product-list",
|
|
2122
|
+
type: "entityList",
|
|
2123
|
+
entity: "product",
|
|
2124
|
+
columns: ["name"],
|
|
2125
|
+
rowActions: [
|
|
2126
|
+
{
|
|
2127
|
+
kind: "navigate",
|
|
2128
|
+
id: "edit",
|
|
2129
|
+
label: "actions.edit",
|
|
2130
|
+
screen: "invoice-edit",
|
|
2131
|
+
entityId: "invoiceId",
|
|
2132
|
+
},
|
|
2133
|
+
],
|
|
2134
|
+
});
|
|
2135
|
+
});
|
|
2136
|
+
const consumer = defineFeature("billing", (r) => {
|
|
2137
|
+
r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
|
|
2138
|
+
r.screen({
|
|
2139
|
+
id: "invoice-edit",
|
|
2140
|
+
type: "entityEdit",
|
|
2141
|
+
entity: "invoice",
|
|
2142
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
2143
|
+
});
|
|
2144
|
+
});
|
|
2145
|
+
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
test("same-feature navigate to entityEdit WITHOUT entityId → kein Throw (same-feature fallback applies)", () => {
|
|
2149
|
+
const feature = defineFeature("shop", (r) => {
|
|
2150
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2151
|
+
r.screen({
|
|
2152
|
+
id: "product-list",
|
|
2153
|
+
type: "entityList",
|
|
2154
|
+
entity: "product",
|
|
2155
|
+
columns: ["name"],
|
|
2156
|
+
rowActions: [
|
|
2157
|
+
{ kind: "navigate", id: "edit", label: "actions.edit", screen: "product-edit" },
|
|
2158
|
+
],
|
|
2159
|
+
});
|
|
2160
|
+
r.screen({
|
|
2161
|
+
id: "product-edit",
|
|
2162
|
+
type: "entityEdit",
|
|
2163
|
+
entity: "product",
|
|
2164
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
2168
|
+
});
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
// --- Screen short-id collision across features ---
|
|
2172
|
+
describe("screen short-id collisions across features", () => {
|
|
2173
|
+
test("two features registering the same short screen-id → Throw", () => {
|
|
2174
|
+
const a = defineFeature("shop", (r) => {
|
|
2175
|
+
r.screen({ id: "settings", type: "custom", renderer: { react: "stub" } });
|
|
2176
|
+
});
|
|
2177
|
+
const b = defineFeature("billing", (r) => {
|
|
2178
|
+
r.screen({ id: "settings", type: "custom", renderer: { react: "stub" } });
|
|
2179
|
+
});
|
|
2180
|
+
expect(() => validateBoot([a, b])).toThrow(
|
|
2181
|
+
/Screen short-id "settings" is registered by 2 features \(shop, billing\)/,
|
|
2182
|
+
);
|
|
2183
|
+
});
|
|
2184
|
+
|
|
2185
|
+
test("distinct short-ids across features → kein Throw", () => {
|
|
2186
|
+
const a = defineFeature("shop", (r) => {
|
|
2187
|
+
r.screen({ id: "shop-settings", type: "custom", renderer: { react: "stub" } });
|
|
2188
|
+
});
|
|
2189
|
+
const b = defineFeature("billing", (r) => {
|
|
2190
|
+
r.screen({ id: "billing-settings", type: "custom", renderer: { react: "stub" } });
|
|
2191
|
+
});
|
|
2192
|
+
expect(() => validateBoot([a, b])).not.toThrow();
|
|
2193
|
+
});
|
|
1984
2194
|
});
|
|
1985
2195
|
|
|
1986
2196
|
// --- Tier 2.7e: rowAction rowClick (Row-Body-Klick) ---
|
|
@@ -256,6 +256,27 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
|
256
256
|
expect(out.navs.find((n) => n.id === "audience-tenant")?.access).toEqual({ openToAll: true });
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
test("openToAll collapse holds regardless of key declaration order (516/2)", () => {
|
|
260
|
+
// The test above only ever puts the openToAll key first, so
|
|
261
|
+
// unionAccessRules's short-circuit (`if ("openToAll" in rule) return`)
|
|
262
|
+
// never actually has to walk past a role-restricted rule to prove it —
|
|
263
|
+
// `gated` here comes FIRST so a hypothetical short-circuit-on-first-only
|
|
264
|
+
// implementation would fail this one even though it passed the other.
|
|
265
|
+
const mixedWriteReversed = defineFeature("mixedwritereversed", (r) => {
|
|
266
|
+
r.config({
|
|
267
|
+
keys: {
|
|
268
|
+
gated: createTenantConfig("text", { mask: { title: "mwr.gated" } }), // default admin write
|
|
269
|
+
open: createTenantConfig("text", { write: access.all, mask: { title: "mwr.open" } }),
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
const out = buildConfigFeatureSchema(createRegistry([mixedWriteReversed]));
|
|
274
|
+
const screen = out.screens.find((s) => s.id === "mixedwritereversed-tenant");
|
|
275
|
+
if (screen?.type !== "configEdit")
|
|
276
|
+
throw new Error('expected configEdit screen "mixedwritereversed-tenant"');
|
|
277
|
+
expect(screen.access).toEqual({ openToAll: true });
|
|
278
|
+
});
|
|
279
|
+
|
|
259
280
|
test("returns empty (no workspace) when no key opts into the hub via mask", () => {
|
|
260
281
|
const plain = defineFeature("plain", (r) => {
|
|
261
282
|
r.config({ keys: { secret: createSystemConfig("text", {}) } });
|
|
@@ -29,8 +29,14 @@ describe("defineFeature — bare CRUD verb → entity mapping", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
test("feature-name match wins (the fallback is the secondary path)", () => {
|
|
32
|
+
// Two entities (516/1): with only "note" registered, this would also
|
|
33
|
+
// pass via the single-entity fallback, proving nothing about the
|
|
34
|
+
// name-match branch specifically. A second entity ("tag") disables the
|
|
35
|
+
// fallback (entityKeys.length !== 1), so success here can only come
|
|
36
|
+
// from entities[name] matching — the branch this test claims to cover.
|
|
32
37
|
const f = defineFeature("note", (r) => {
|
|
33
38
|
r.entity("note", noteEntity);
|
|
39
|
+
r.entity("tag", tagEntity);
|
|
34
40
|
r.writeHandler(bareCreate);
|
|
35
41
|
});
|
|
36
42
|
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|