@cosmicdrift/kumiko-framework 0.249.0 → 0.251.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/__tests__/full-stack.integration.test.ts +3 -1
- package/src/crypto/__tests__/event-pii.test.ts +25 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
- package/src/crypto/event-pii.ts +7 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/kms-wiring.ts +23 -10
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
- package/src/db/entity-table-meta.ts +9 -2
- package/src/db/event-store-executor-read.ts +124 -7
- package/src/db/table-builder.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +132 -2
- package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +184 -0
- package/src/engine/boot-validator/index.ts +5 -1
- package/src/engine/boot-validator/projection-list-screens.ts +42 -0
- package/src/engine/boot-validator/screens.ts +45 -29
- package/src/engine/entity-handlers.ts +4 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
- package/src/engine/feature-ast/extractors/events.ts +24 -0
- package/src/engine/feature-ast/patcher.ts +6 -1
- package/src/engine/feature-ast/patterns.ts +6 -0
- package/src/engine/feature-ast/render.ts +3 -4
- package/src/engine/feature-config-events-jobs.ts +33 -9
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/registry-facade.ts +10 -1
- package/src/engine/registry-state.ts +8 -0
- package/src/engine/registry-validate.ts +54 -3
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
- package/src/jobs/job-runner.ts +12 -4
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
- package/src/pipeline/system-hooks.ts +94 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.251.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.251.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.251.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -63,7 +63,9 @@ async function emitUserCreated(
|
|
|
63
63
|
const userFeature = defineFeature("users", (r) => {
|
|
64
64
|
const user = r.entity("user", userEntity);
|
|
65
65
|
|
|
66
|
-
const userCreated = r.defineEvent("user.created", z.object({ id: z.any(), email: z.string() })
|
|
66
|
+
const userCreated = r.defineEvent("user.created", z.object({ id: z.any(), email: z.string() }), {
|
|
67
|
+
piiFields: "none",
|
|
68
|
+
});
|
|
67
69
|
USER_CREATED_EVENT = userCreated.name;
|
|
68
70
|
|
|
69
71
|
// r.multiStreamProjection: capture USER_CREATED_EVENT asynchronously via
|
|
@@ -83,12 +83,34 @@ describe("defineEvent piiFields validation", () => {
|
|
|
83
83
|
).toThrow(/cannot use itself as subjectField/);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("
|
|
86
|
+
test("omitting the options argument throws an explicit PII stance error (fw#2558)", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
defineFeature("mailer", (r) => {
|
|
89
|
+
// @cast-boundary test-only — the type signature makes `options`
|
|
90
|
+
// mandatory, but an untyped JS consumer can still omit it at
|
|
91
|
+
// runtime; the registrar must fail closed for that caller too.
|
|
92
|
+
(r.defineEvent as (name: string, schema: typeof attemptSchema) => unknown)(
|
|
93
|
+
"attempt",
|
|
94
|
+
attemptSchema,
|
|
95
|
+
);
|
|
96
|
+
}),
|
|
97
|
+
).toThrow(/explicit PII stance/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('piiFields: {} throws, recommending piiFields: "none"', () => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
defineFeature("mailer", (r) => {
|
|
103
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: {} });
|
|
104
|
+
}),
|
|
105
|
+
).toThrow(/use piiFields: "none"/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('piiFields: "none" registers successfully and stays out of the catalog', () => {
|
|
87
109
|
const feature = defineFeature("mailer", (r) => {
|
|
88
|
-
r.defineEvent("attempt", attemptSchema);
|
|
110
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: "none" });
|
|
89
111
|
});
|
|
90
112
|
createRegistry([feature]);
|
|
91
|
-
expect(configuredEventPiiCatalog().
|
|
113
|
+
expect(configuredEventPiiCatalog().has(EVENT_TYPE)).toBe(false);
|
|
92
114
|
});
|
|
93
115
|
});
|
|
94
116
|
|
|
@@ -95,7 +95,9 @@ describe("resolveKmsWiring", () => {
|
|
|
95
95
|
test("falls back to plaintext PII when the trio is entirely absent", () => {
|
|
96
96
|
const wiring = resolveKmsWiring({});
|
|
97
97
|
|
|
98
|
-
expect(wiring).
|
|
98
|
+
expect("kms" in wiring).toBe(false);
|
|
99
|
+
if ("kms" in wiring) throw new Error("unreachable");
|
|
100
|
+
expect(wiring.allowPlaintextPii).toBe("local dev without subject-keys KMS (fw#818)");
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
// Regression: every member being optional made TypeScript's weak-type
|
|
@@ -111,7 +113,18 @@ describe("resolveKmsWiring", () => {
|
|
|
111
113
|
test("carries an app-supplied fallback reason into the boot log", () => {
|
|
112
114
|
const wiring = resolveKmsWiring({}, { plaintextReason: "solon pre-UI gate" });
|
|
113
115
|
|
|
114
|
-
expect(wiring).
|
|
116
|
+
expect("kms" in wiring).toBe(false);
|
|
117
|
+
if ("kms" in wiring) throw new Error("unreachable");
|
|
118
|
+
expect(wiring.allowPlaintextPii).toBe("solon pre-UI gate");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// fw#2551: an ops script must be able to release the subject-keys pool in a
|
|
122
|
+
// `finally` without knowing which branch it got — this compiles only while
|
|
123
|
+
// `close` sits on BOTH union members, and the plaintext branch must resolve
|
|
124
|
+
// rather than blow up on a wiring that opened nothing.
|
|
125
|
+
test("exposes close on both branches, callable without narrowing", async () => {
|
|
126
|
+
await expect(resolveKmsWiring({}).close()).resolves.toBeUndefined();
|
|
127
|
+
await expect(resolveKmsWiring(fullTrio).close()).resolves.toBeUndefined();
|
|
115
128
|
});
|
|
116
129
|
|
|
117
130
|
// All-or-none is the core of this module: a partial trio means someone
|
|
@@ -3,6 +3,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
3
3
|
import postgres from "postgres";
|
|
4
4
|
import { createTestDb } from "../../stack/db";
|
|
5
5
|
import { type KmsContext, type SubjectId, subjectIdToKey } from "../kms-adapter";
|
|
6
|
+
import { resolveKmsWiring } from "../kms-wiring";
|
|
6
7
|
import { PgKmsAdapter } from "../pg-kms-adapter";
|
|
7
8
|
import { describeKmsAdapterContract } from "./kms-adapter-contract";
|
|
8
9
|
|
|
@@ -133,4 +134,21 @@ describe("PgKmsAdapter — pg specifics", () => {
|
|
|
133
134
|
expect(rows[0]?.["erased_by"]).toBe("operator-1");
|
|
134
135
|
expect(rows[0]?.["erase_reason"]).toBe("user-forget");
|
|
135
136
|
});
|
|
137
|
+
|
|
138
|
+
// fw#2551: the pool resolveKmsWiring opens is what kept ops scripts from
|
|
139
|
+
// exiting. Proving release needs a pool that was genuinely open, so the
|
|
140
|
+
// health() before close is half the assertion.
|
|
141
|
+
test("resolveKmsWiring's close releases the pool it opened", async () => {
|
|
142
|
+
const wiring = resolveKmsWiring({
|
|
143
|
+
PLATFORM_KEK: platformKek,
|
|
144
|
+
SUBJECT_KEYS_DATABASE_URL: databaseUrl,
|
|
145
|
+
KUMIKO_BLIND_INDEX_KEY: "blind-index-key",
|
|
146
|
+
});
|
|
147
|
+
if (!("kms" in wiring)) throw new Error("unreachable");
|
|
148
|
+
|
|
149
|
+
expect((await wiring.kms.health()).ok).toBe(true);
|
|
150
|
+
await wiring.close();
|
|
151
|
+
|
|
152
|
+
await expect(wiring.kms.health()).rejects.toThrow();
|
|
153
|
+
});
|
|
136
154
|
});
|
package/src/crypto/event-pii.ts
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
// path at all. `r.defineEvent(name, schema, { piiFields })` declares which
|
|
5
5
|
// payload fields are PII and which payload field names the owning user;
|
|
6
6
|
// createRegistry publishes the catalog and append() — the single write funnel
|
|
7
|
-
// into kumiko_events — encrypts every catalogued field.
|
|
7
|
+
// into kumiko_events — encrypts every catalogued field.
|
|
8
|
+
//
|
|
9
|
+
// `piiFields` is a mandatory, explicit stance (fw#2558): a payload can only
|
|
10
|
+
// go uncatalogued through a declared `piiFields: "none"`, never by omission —
|
|
11
|
+
// defineEvent refuses to register an event without one. What used to be a
|
|
12
|
+
// silent no-op (forgetting the option) is now either an explicit "none" or a
|
|
13
|
+
// missing subject KMS at encrypt-time, both visible states, not a gap.
|
|
8
14
|
|
|
9
15
|
import { requestContext } from "../api/request-context";
|
|
10
16
|
import type { EventPiiFields } from "../engine/types/handlers";
|
package/src/crypto/index.ts
CHANGED
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -33,14 +33,22 @@ export type PgKmsRotationEnv = KmsWiringEnv & {
|
|
|
33
33
|
readonly SUBJECT_KEYS_DATABASE_URL: string;
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
/** Releases what the wiring opened. Present on BOTH branches (a no-op on the
|
|
37
|
+
* plaintext fallback) so a short-lived process can `finally { await
|
|
38
|
+
* wiring.close() }` without narrowing first. Skipping it leaves the adapter's
|
|
39
|
+
* connection pool holding the event loop open and the process never exits —
|
|
40
|
+
* a long-running server is supposed to hold it, every ops script is not
|
|
41
|
+
* (fw#2551). */
|
|
42
|
+
export type KmsWiringRelease = { readonly close: () => Promise<void> };
|
|
43
|
+
|
|
44
|
+
export type ActiveKmsWiring = KmsWiringRelease & {
|
|
37
45
|
readonly kms: PgKmsAdapter;
|
|
38
46
|
readonly blindIndexKey: string;
|
|
39
47
|
};
|
|
40
48
|
|
|
41
49
|
/** Plaintext fallback carries its reason so the boot log says WHY PII is
|
|
42
50
|
* unencrypted — an app running like this by accident is a reportable breach. */
|
|
43
|
-
export type PlaintextPiiWiring = { readonly allowPlaintextPii: string };
|
|
51
|
+
export type PlaintextPiiWiring = KmsWiringRelease & { readonly allowPlaintextPii: string };
|
|
44
52
|
|
|
45
53
|
export type KmsWiring = ActiveKmsWiring | PlaintextPiiWiring;
|
|
46
54
|
|
|
@@ -141,18 +149,23 @@ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined):
|
|
|
141
149
|
export function resolveKmsWiring(env: KmsWiringEnv, options: KmsWiringOptions = {}): KmsWiring {
|
|
142
150
|
const complete = assertTrioConsistent(env, options.logPrefix);
|
|
143
151
|
if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
|
|
152
|
+
const kms = createPgKmsAdapter(
|
|
153
|
+
buildPgKmsOptions({
|
|
154
|
+
...env,
|
|
155
|
+
PLATFORM_KEK: env.PLATFORM_KEK,
|
|
156
|
+
SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
144
159
|
return {
|
|
145
|
-
kms
|
|
146
|
-
buildPgKmsOptions({
|
|
147
|
-
...env,
|
|
148
|
-
PLATFORM_KEK: env.PLATFORM_KEK,
|
|
149
|
-
SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
|
|
150
|
-
}),
|
|
151
|
-
),
|
|
160
|
+
kms,
|
|
152
161
|
blindIndexKey: env.KUMIKO_BLIND_INDEX_KEY,
|
|
162
|
+
close: () => kms.close(),
|
|
153
163
|
};
|
|
154
164
|
}
|
|
155
|
-
return {
|
|
165
|
+
return {
|
|
166
|
+
allowPlaintextPii: options.plaintextReason ?? DEFAULT_PLAINTEXT_REASON,
|
|
167
|
+
close: () => Promise.resolve(),
|
|
168
|
+
};
|
|
156
169
|
}
|
|
157
170
|
|
|
158
171
|
/** Same as `resolveKmsWiring` but the trio is mandatory — an absent trio throws
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// fw#2741 — a list sorted by a `sortable` reference field must order by the
|
|
2
|
+
// target row's labelField, not by the FK column's UUID. The fixture pins three
|
|
3
|
+
// mutually distinct orderings (label / own id / referenced UUID) so a passing
|
|
4
|
+
// assertion can only come from the label expression, never from the column the
|
|
5
|
+
// read path used to fall back to.
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
9
|
+
import { asRawClient } from "../../db/query";
|
|
10
|
+
import { createEntity, createTextField } from "../../engine";
|
|
11
|
+
import type { EntityDefinition } from "../../engine/types";
|
|
12
|
+
import { createEventsTable } from "../../event-store";
|
|
13
|
+
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
14
|
+
import { seedRows } from "../../testing";
|
|
15
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
16
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
17
|
+
import { buildEntityTable } from "../table-builder";
|
|
18
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
19
|
+
|
|
20
|
+
const customerEntity = createEntity({
|
|
21
|
+
table: "read_ref_sort_customers",
|
|
22
|
+
fields: { name: createTextField({ required: true }) },
|
|
23
|
+
});
|
|
24
|
+
const customerTable = buildEntityTable("refSortCustomer", customerEntity);
|
|
25
|
+
|
|
26
|
+
const orderEntity = createEntity({
|
|
27
|
+
table: "read_ref_sort_orders",
|
|
28
|
+
fields: {
|
|
29
|
+
note: createTextField(),
|
|
30
|
+
customerId: {
|
|
31
|
+
type: "reference",
|
|
32
|
+
entity: "refSortCustomer",
|
|
33
|
+
labelField: "name",
|
|
34
|
+
sortable: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const orderTable = buildEntityTable("refSortOrder", orderEntity);
|
|
39
|
+
|
|
40
|
+
// Same shape, but the reference column is Admin-only: a caller who cannot read
|
|
41
|
+
// the column must not be able to probe its target's label through the ordering
|
|
42
|
+
// (fw#2629 parity — the read path gates sorting like projection).
|
|
43
|
+
const restrictedOrderEntity = createEntity({
|
|
44
|
+
table: "read_ref_sort_restricted_orders",
|
|
45
|
+
fields: {
|
|
46
|
+
note: createTextField(),
|
|
47
|
+
customerId: {
|
|
48
|
+
type: "reference",
|
|
49
|
+
entity: "refSortCustomer",
|
|
50
|
+
labelField: "name",
|
|
51
|
+
sortable: true,
|
|
52
|
+
access: { read: { Admin: "all" } },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const restrictedOrderTable = buildEntityTable("refSortRestrictedOrder", restrictedOrderEntity);
|
|
57
|
+
|
|
58
|
+
const CUSTOMER_MIKE = "11111111-1111-4111-8111-111111111111";
|
|
59
|
+
const CUSTOMER_ALPHA = "22222222-2222-4222-8222-222222222222";
|
|
60
|
+
const CUSTOMER_ZULU = "33333333-3333-4333-8333-333333333333";
|
|
61
|
+
|
|
62
|
+
// Own ids ascend a < b < c while the labels they point at do not, so the three
|
|
63
|
+
// candidate orderings are pairwise different:
|
|
64
|
+
// by label: [orderC, orderB, orderA] (Alpha, Mike, Zulu)
|
|
65
|
+
// by own id: [orderA, orderB, orderC]
|
|
66
|
+
// by customerId UUID: [orderB, orderC, orderA] (1111…, 2222…, 3333…)
|
|
67
|
+
const ORDER_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
68
|
+
const ORDER_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
|
69
|
+
const ORDER_C = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
|
70
|
+
|
|
71
|
+
function resolveEntity(name: string): EntityDefinition | undefined {
|
|
72
|
+
return name === "refSortCustomer" ? customerEntity : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const referenceSort = {
|
|
76
|
+
fields: [{ fieldName: "customerId", targetEntityName: "refSortCustomer", labelField: "name" }],
|
|
77
|
+
resolveEntity,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let testDb: BunTestDb;
|
|
81
|
+
let tdbA: TenantDb;
|
|
82
|
+
const admin = TestUsers.admin;
|
|
83
|
+
const otherTenantAdmin = TestUsers.otherTenant;
|
|
84
|
+
const orderExec = createEventStoreExecutor(orderTable, orderEntity, { entityName: "refSortOrder" });
|
|
85
|
+
const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
|
|
86
|
+
entityName: "refSortRestrictedOrder",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function seedFixture(): Promise<void> {
|
|
90
|
+
await seedRows(testDb.db, customerTable, [
|
|
91
|
+
{ id: CUSTOMER_MIKE, tenantId: admin.tenantId, name: "Mike" },
|
|
92
|
+
{ id: CUSTOMER_ALPHA, tenantId: admin.tenantId, name: "Alpha" },
|
|
93
|
+
{ id: CUSTOMER_ZULU, tenantId: admin.tenantId, name: "Zulu" },
|
|
94
|
+
]);
|
|
95
|
+
await seedRows(testDb.db, orderTable, [
|
|
96
|
+
{ id: ORDER_A, tenantId: admin.tenantId, note: "a", customerId: CUSTOMER_ZULU },
|
|
97
|
+
{ id: ORDER_B, tenantId: admin.tenantId, note: "b", customerId: CUSTOMER_MIKE },
|
|
98
|
+
{ id: ORDER_C, tenantId: admin.tenantId, note: "c", customerId: CUSTOMER_ALPHA },
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const idsOf = (rows: readonly Record<string, unknown>[]): unknown[] => rows.map((r) => r["id"]);
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
await ensureTemporalPolyfill();
|
|
106
|
+
testDb = await createTestDb();
|
|
107
|
+
await unsafeCreateEntityTable(testDb.db, customerEntity, "refSortCustomer");
|
|
108
|
+
await unsafeCreateEntityTable(testDb.db, orderEntity, "refSortOrder");
|
|
109
|
+
await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSortRestrictedOrder");
|
|
110
|
+
await createEventsTable(testDb.db);
|
|
111
|
+
tdbA = createTenantDb(testDb.db, admin.tenantId);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterAll(async () => {
|
|
115
|
+
await testDb.cleanup();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
beforeEach(async () => {
|
|
119
|
+
await asRawClient(testDb.db).unsafe(
|
|
120
|
+
"TRUNCATE kumiko_events, read_ref_sort_customers, read_ref_sort_orders, " +
|
|
121
|
+
"read_ref_sort_restricted_orders RESTART IDENTITY CASCADE",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("event-store-executor.list — sortable reference fields (fw#2741)", () => {
|
|
126
|
+
test("orders ascending by the target row's label, not by the FK UUID", async () => {
|
|
127
|
+
await seedFixture();
|
|
128
|
+
|
|
129
|
+
const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
|
|
130
|
+
referenceSort,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(idsOf(res.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("orders descending by the target row's label", async () => {
|
|
137
|
+
await seedFixture();
|
|
138
|
+
|
|
139
|
+
const res = await orderExec.list({ sort: "customerId", sortDirection: "desc" }, admin, tdbA, {
|
|
140
|
+
referenceSort,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("does not leak the sort-label projection into the returned rows", async () => {
|
|
147
|
+
await seedFixture();
|
|
148
|
+
|
|
149
|
+
const res = await orderExec.list({ sort: "customerId" }, admin, tdbA, { referenceSort });
|
|
150
|
+
|
|
151
|
+
for (const row of res.rows) {
|
|
152
|
+
expect(Object.keys(row)).not.toContain("__kumiko_sort_label");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("pages through the label order with a keyset cursor, no gaps or repeats", async () => {
|
|
157
|
+
await seedFixture();
|
|
158
|
+
|
|
159
|
+
const page1 = await orderExec.list(
|
|
160
|
+
{ sort: "customerId", sortDirection: "asc", limit: 2, totalCount: true },
|
|
161
|
+
admin,
|
|
162
|
+
tdbA,
|
|
163
|
+
{ referenceSort },
|
|
164
|
+
);
|
|
165
|
+
expect(idsOf(page1.rows)).toEqual([ORDER_C, ORDER_B]);
|
|
166
|
+
expect(page1.total).toBe(3);
|
|
167
|
+
expect(page1.nextCursor).not.toBeNull();
|
|
168
|
+
|
|
169
|
+
const page2 = await orderExec.list(
|
|
170
|
+
{
|
|
171
|
+
sort: "customerId",
|
|
172
|
+
sortDirection: "asc",
|
|
173
|
+
limit: 2,
|
|
174
|
+
cursor: page1.nextCursor as string,
|
|
175
|
+
totalCount: true,
|
|
176
|
+
},
|
|
177
|
+
admin,
|
|
178
|
+
tdbA,
|
|
179
|
+
{ referenceSort },
|
|
180
|
+
);
|
|
181
|
+
expect(idsOf(page2.rows)).toEqual([ORDER_A]);
|
|
182
|
+
// totalCount counts the same WHERE the page used, so a cursored page counts
|
|
183
|
+
// what is left. The assertion that matters here is that it answers at all:
|
|
184
|
+
// the count binds only the WHERE params while ORDER BY now adds its own,
|
|
185
|
+
// and Postgres rejects a statement handed more parameters than it uses.
|
|
186
|
+
expect(page2.total).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a target row in a foreign tenant contributes no label and sorts last", async () => {
|
|
190
|
+
await seedFixture();
|
|
191
|
+
const foreignCustomer = "44444444-4444-4444-8444-444444444444";
|
|
192
|
+
await seedRows(testDb.db, customerTable, [
|
|
193
|
+
// Reference UUIDs carry no FK constraint, so a tenant-A row can point at
|
|
194
|
+
// a tenant-B target. The tenant-scoped lookup must not resolve it —
|
|
195
|
+
// otherwise "AAA Foreign" would sort first and leak its existence.
|
|
196
|
+
{ id: foreignCustomer, tenantId: otherTenantAdmin.tenantId, name: "AAA Foreign" },
|
|
197
|
+
]);
|
|
198
|
+
const orderD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
|
|
199
|
+
await seedRows(testDb.db, orderTable, [
|
|
200
|
+
{ id: orderD, tenantId: admin.tenantId, note: "d", customerId: foreignCustomer },
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
|
|
204
|
+
referenceSort,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(idsOf(res.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A, orderD]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a reference field that did not opt into sortable falls back to id order, never UUID order", async () => {
|
|
211
|
+
await seedFixture();
|
|
212
|
+
|
|
213
|
+
const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
|
|
214
|
+
referenceSort: { fields: [], resolveEntity },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("an unresolvable target entity falls back to id order, never UUID order", async () => {
|
|
221
|
+
await seedFixture();
|
|
222
|
+
|
|
223
|
+
const res = await orderExec.list({ sort: "customerId", sortDirection: "asc" }, admin, tdbA, {
|
|
224
|
+
referenceSort: { fields: referenceSort.fields, resolveEntity: () => undefined },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(idsOf(res.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a caller without read access to the reference column gets id order, an Admin gets label order", async () => {
|
|
231
|
+
await seedRows(testDb.db, customerTable, [
|
|
232
|
+
{ id: CUSTOMER_MIKE, tenantId: admin.tenantId, name: "Mike" },
|
|
233
|
+
{ id: CUSTOMER_ALPHA, tenantId: admin.tenantId, name: "Alpha" },
|
|
234
|
+
{ id: CUSTOMER_ZULU, tenantId: admin.tenantId, name: "Zulu" },
|
|
235
|
+
]);
|
|
236
|
+
await seedRows(testDb.db, restrictedOrderTable, [
|
|
237
|
+
{ id: ORDER_A, tenantId: admin.tenantId, note: "a", customerId: CUSTOMER_ZULU },
|
|
238
|
+
{ id: ORDER_B, tenantId: admin.tenantId, note: "b", customerId: CUSTOMER_MIKE },
|
|
239
|
+
{ id: ORDER_C, tenantId: admin.tenantId, note: "c", customerId: CUSTOMER_ALPHA },
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
const asUser = await restrictedOrderExec.list(
|
|
243
|
+
{ sort: "customerId", sortDirection: "asc" },
|
|
244
|
+
TestUsers.user,
|
|
245
|
+
tdbA,
|
|
246
|
+
{ referenceSort },
|
|
247
|
+
);
|
|
248
|
+
expect(idsOf(asUser.rows)).toEqual([ORDER_A, ORDER_B, ORDER_C]);
|
|
249
|
+
|
|
250
|
+
const asAdmin = await restrictedOrderExec.list(
|
|
251
|
+
{ sort: "customerId", sortDirection: "asc" },
|
|
252
|
+
admin,
|
|
253
|
+
tdbA,
|
|
254
|
+
{ referenceSort },
|
|
255
|
+
);
|
|
256
|
+
expect(idsOf(asAdmin.rows)).toEqual([ORDER_C, ORDER_B, ORDER_A]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -120,6 +120,23 @@ describe("lock-step — softDelete + explizite Indexes", () => {
|
|
|
120
120
|
);
|
|
121
121
|
expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
|
|
122
122
|
});
|
|
123
|
+
|
|
124
|
+
test("explicit unique index without author where gets the soft-delete predicate (#2593)", () => {
|
|
125
|
+
const idx = fromMeta.indexes.find((i) => i.name.endsWith("_owner_id_status_unique"));
|
|
126
|
+
expect(idx?.unique).toBe(true);
|
|
127
|
+
expect(idx?.whereSql).toBe('"is_deleted" = false');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("author-provided where is left unchanged, no is_deleted appended (#2593)", () => {
|
|
131
|
+
const idx = fromMeta.indexes.find((i) => i.name === "open_title_unique");
|
|
132
|
+
expect(idx?.whereSql).toBe("status = 'open'");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("non-unique explicit index gets no predicate at all (#2593)", () => {
|
|
136
|
+
const idx = fromMeta.indexes.find((i) => i.name.endsWith("_owner_id_idx"));
|
|
137
|
+
expect(idx?.unique).toBeUndefined();
|
|
138
|
+
expect(idx?.whereSql).toBeUndefined();
|
|
139
|
+
});
|
|
123
140
|
});
|
|
124
141
|
|
|
125
142
|
// Dritte Probe: lookupable-Feld (#818) — bidx-Spalte, bidx-Index und das
|
|
@@ -159,6 +176,14 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
|
159
176
|
expect(partial?.columns).toEqual(["tenant_slug", "email_bidx"]);
|
|
160
177
|
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL');
|
|
161
178
|
});
|
|
179
|
+
|
|
180
|
+
test("without softDelete, the plaintext unique index gets no predicate (#2593)", () => {
|
|
181
|
+
const plaintext = fromMeta.indexes.find(
|
|
182
|
+
(i) => i.name.endsWith("_tenant_slug_email_unique") && !i.name.endsWith("_bidx"),
|
|
183
|
+
);
|
|
184
|
+
expect(plaintext?.unique).toBe(true);
|
|
185
|
+
expect(plaintext?.whereSql).toBeUndefined();
|
|
186
|
+
});
|
|
162
187
|
});
|
|
163
188
|
|
|
164
189
|
// Fourth probe: softDelete + lookupable (#2464) — the partial bidx unique
|
|
@@ -188,4 +213,12 @@ describe("lock-step — softDelete + lookupable/blind-index (#2464)", () => {
|
|
|
188
213
|
expect(partial).toBeDefined();
|
|
189
214
|
expect(partial?.whereSql).toBe('"email_bidx" IS NOT NULL AND "is_deleted" = false');
|
|
190
215
|
});
|
|
216
|
+
|
|
217
|
+
test("plaintext unique index also gets the soft-delete predicate (#2593)", () => {
|
|
218
|
+
const plaintext = fromMeta.indexes.find(
|
|
219
|
+
(i) => i.name.endsWith("_tenant_slug_email_unique") && !i.name.endsWith("_bidx"),
|
|
220
|
+
);
|
|
221
|
+
expect(plaintext?.unique).toBe(true);
|
|
222
|
+
expect(plaintext?.whereSql).toBe('"is_deleted" = false');
|
|
223
|
+
});
|
|
191
224
|
});
|
|
@@ -338,13 +338,20 @@ export function deriveEntityTableMeta(
|
|
|
338
338
|
);
|
|
339
339
|
const suffix = def.unique === true ? "unique" : "idx";
|
|
340
340
|
const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
|
|
341
|
-
const
|
|
341
|
+
const explicitWhereSql = sqlExpressionText(def.where);
|
|
342
|
+
// Soft-deleted rows keep their value, so a full unique index would
|
|
343
|
+
// permanently block reuse; kept in lock-step with table-builder.ts (framework#2593).
|
|
344
|
+
const whereSql =
|
|
345
|
+
explicitWhereSql ??
|
|
346
|
+
(def.where === undefined && def.unique === true && entity.softDelete === true
|
|
347
|
+
? `"is_deleted" = false`
|
|
348
|
+
: undefined);
|
|
342
349
|
indexes.push({
|
|
343
350
|
name: indexName,
|
|
344
351
|
columns: cols,
|
|
345
352
|
...(def.unique === true && { unique: true }),
|
|
346
353
|
...(whereSql !== undefined && { whereSql }),
|
|
347
|
-
...(def.where !== undefined &&
|
|
354
|
+
...(def.where !== undefined && explicitWhereSql === undefined && { needsManualWhere: true }),
|
|
348
355
|
});
|
|
349
356
|
// Unique index over lookupable columns: partial bidx counterpart, so
|
|
350
357
|
// uniqueness also holds for encrypted rows. The original stays
|