@cosmicdrift/kumiko-framework 0.165.1 → 0.165.2
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 +1 -1
- package/package.json +3 -3
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +7 -3
- package/src/api/routes.ts +3 -3
- package/src/api/sse-broker.ts +4 -5
- package/src/bun-db/query.ts +2 -2
- package/src/db/__tests__/decimal-field.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
- package/src/db/__tests__/migrate-runner.test.ts +15 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
- package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -4
- package/src/db/collect-table-metas.ts +3 -3
- package/src/db/entity-table-meta.ts +49 -32
- package/src/db/index.ts +5 -1
- package/src/db/migrate-runner.ts +5 -10
- package/src/db/table-builder.ts +2 -2
- package/src/db/tenant-db.ts +1 -1
- package/src/engine/__tests__/store-table.test.ts +8 -11
- package/src/engine/constants.ts +0 -4
- package/src/engine/feature-ast/extractors/round5.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +1 -1
- package/src/engine/registry-state.ts +2 -2
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
- package/src/migrations/projection-table-index.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +61 -0
- package/src/pipeline/dispatch-stream.ts +16 -9
package/README.md
CHANGED
|
@@ -79,7 +79,7 @@ export const taskFeature = defineFeature("tasks", (r) => {
|
|
|
79
79
|
| Entry | What's in it |
|
|
80
80
|
|---|---|
|
|
81
81
|
| `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
|
|
82
|
-
| `@cosmicdrift/kumiko-framework/db` | `
|
|
82
|
+
| `@cosmicdrift/kumiko-framework/db` | `deriveEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
|
|
83
83
|
| `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
|
|
84
84
|
| `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
|
|
85
85
|
| `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.165.
|
|
3
|
+
"version": "0.165.2",
|
|
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>",
|
|
@@ -197,10 +197,10 @@
|
|
|
197
197
|
"zod": "^4.4.3"
|
|
198
198
|
},
|
|
199
199
|
"peerDependencies": {
|
|
200
|
-
"@cosmicdrift/kumiko-types": "^0.165.
|
|
200
|
+
"@cosmicdrift/kumiko-types": "^0.165.2"
|
|
201
201
|
},
|
|
202
202
|
"devDependencies": {
|
|
203
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.165.
|
|
203
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.165.2",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"pino-pretty": "^13.1.3"
|
|
206
206
|
},
|
|
@@ -128,10 +128,14 @@ describe("AuthRoutesConfig.trustedProxyHops", () => {
|
|
|
128
128
|
expect(third.status).toBe(429);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
test("hops=1 with no XFF
|
|
131
|
+
test("hops=1 with no XFF and no X-Real-IP collapses distinct clients into unknown", async () => {
|
|
132
132
|
const app = await buildApp({ trustedProxyHops: 1 });
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
// Three requests without either header must share the "unknown" bucket —
|
|
134
|
+
// a single 422 only proves "no throw", not the collapse (fw#1555#2).
|
|
135
|
+
const attempt = () => app.request(verifyRequest(undefined));
|
|
136
|
+
expect((await attempt()).status).toBe(422);
|
|
137
|
+
expect((await attempt()).status).toBe(422);
|
|
138
|
+
expect((await attempt()).status).toBe(429);
|
|
135
139
|
});
|
|
136
140
|
|
|
137
141
|
test("hops=1 with short/missing XFF uses x-real-ip so clients stay independent", async () => {
|
package/src/api/routes.ts
CHANGED
|
@@ -19,9 +19,9 @@ import { patAllows } from "./pat-scope";
|
|
|
19
19
|
import { requestContext } from "./request-context";
|
|
20
20
|
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
|
21
21
|
|
|
22
|
-
// SSE frame event names for POST /api/stream
|
|
23
|
-
//
|
|
24
|
-
//
|
|
22
|
+
// SSE frame event names for POST /api/stream. Parallel definition lives in
|
|
23
|
+
// @cosmicdrift/kumiko-headless (dispatcher-live imports that one) — framework
|
|
24
|
+
// cannot depend on headless for four string literals.
|
|
25
25
|
export const StreamFrame = {
|
|
26
26
|
chunk: "chunk",
|
|
27
27
|
ping: "ping",
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -18,16 +18,15 @@ export type SseBroker = {
|
|
|
18
18
|
pushToChannel(channel: string, event: SseEvent): void;
|
|
19
19
|
getClientCount(channel: string): number;
|
|
20
20
|
getTotalClientCount(): number;
|
|
21
|
-
//
|
|
22
|
-
// for mid-stream access revocation. Kept separate from addClient/
|
|
23
|
-
// pushToChannel: those count towards getClientCount/getTotalClientCount
|
|
24
|
-
// (real SSE connections) and their send/close shape doesn't fit a plain
|
|
25
|
-
// callback listener. Returns an unsubscribe function.
|
|
21
|
+
// Separate from addClient so it doesn't count towards getClientCount.
|
|
26
22
|
subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
|
|
27
23
|
publishAccessInvalidation(userId: string): void;
|
|
28
24
|
};
|
|
29
25
|
|
|
30
26
|
export function createSseBroker(): SseBroker {
|
|
27
|
+
// ponytail: in-process only — publishAccessInvalidation does not fan out via
|
|
28
|
+
// Redis. Multi-replica deployments will not revoke SSE streams on other pods
|
|
29
|
+
// (security control is single-node). Upgrade: Redis pub/sub on userAccessChannel.
|
|
31
30
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
32
31
|
const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
|
|
33
32
|
|
package/src/bun-db/query.ts
CHANGED
|
@@ -67,7 +67,7 @@ function isEntityTableMeta(v: unknown): v is EntityTableMeta {
|
|
|
67
67
|
// Resolve any framework table input to its canonical EntityTableMeta:
|
|
68
68
|
// - table()/buildEntityTable outputs carry it under KUMIKO_META_SYMBOL, immune
|
|
69
69
|
// to a column-handle shadowing a meta key.
|
|
70
|
-
// -
|
|
70
|
+
// - deriveEntityTableMeta / defineUnmanagedTable return a plain meta with no
|
|
71
71
|
// handle-spread, so its structural shape is itself unshadowable.
|
|
72
72
|
export function asEntityTableMeta(table: unknown): EntityTableMeta | undefined {
|
|
73
73
|
if (table === null || typeof table !== "object") return undefined;
|
|
@@ -252,7 +252,7 @@ export function extractTableInfo(table: TableLike): TableInfo {
|
|
|
252
252
|
if (!meta) {
|
|
253
253
|
throw new Error(
|
|
254
254
|
"bun-db.extractTableInfo: table is not a kumiko EntityTableMeta — " +
|
|
255
|
-
"build it via buildEntityTable /
|
|
255
|
+
"build it via buildEntityTable / deriveEntityTableMeta / table().",
|
|
256
256
|
);
|
|
257
257
|
}
|
|
258
258
|
const colByField = new Map<string, string>();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createDecimalField, createEntity } from "../../engine/factories";
|
|
3
3
|
import { fieldToZod } from "../../engine/schema-builder";
|
|
4
|
-
import {
|
|
4
|
+
import { deriveEntityTableMeta } from "../entity-table-meta";
|
|
5
5
|
import { coerceRow, extractTableInfo } from "../query";
|
|
6
6
|
import { renderTableDdl } from "../render-ddl";
|
|
7
7
|
import { buildEntityTable } from "../table-builder";
|
|
@@ -25,7 +25,7 @@ const entity = createEntity({
|
|
|
25
25
|
describe("decimal field — column + DDL", () => {
|
|
26
26
|
test("maps to numeric(precision,scale) with required→NOT NULL", () => {
|
|
27
27
|
const cols = new Map(
|
|
28
|
-
|
|
28
|
+
deriveEntityTableMeta("decimalProbe", entity).columns.map((c) => [c.name, c]),
|
|
29
29
|
);
|
|
30
30
|
expect(cols.get("sum")?.pgType).toBe("numeric(14,2)");
|
|
31
31
|
expect(cols.get("interest")?.pgType).toBe("numeric(6,4)");
|
|
@@ -35,7 +35,7 @@ describe("decimal field — column + DDL", () => {
|
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("renders real numeric(p,s) DDL", () => {
|
|
38
|
-
const ddl = renderTableDdl(
|
|
38
|
+
const ddl = renderTableDdl(deriveEntityTableMeta("decimalProbe", entity)).join("\n");
|
|
39
39
|
expect(ddl).toContain('"interest" numeric(6,4) NOT NULL');
|
|
40
40
|
expect(ddl).toContain('"rate" numeric(12,2)');
|
|
41
41
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// #1210:
|
|
1
|
+
// #1210: deriveEntityTableMeta() hardcoded source: "managed", so unmanaged
|
|
2
2
|
// direct-write stores (store_user_sessions, store_api_tokens, mail sync/seen
|
|
3
3
|
// cursors) were misclassified as rebuildable event-sourced projections — a
|
|
4
4
|
// destructive column change would DROP+rebuild-from-events tables that have
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
10
|
import { createEntity, createTextField } from "../../engine";
|
|
11
|
-
import {
|
|
11
|
+
import { defineUnmanagedTable, deriveEntityTableMeta } from "../entity-table-meta";
|
|
12
12
|
import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
|
|
13
13
|
|
|
14
14
|
const entity = createEntity({
|
|
@@ -19,14 +19,14 @@ const entity = createEntity({
|
|
|
19
19
|
},
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
describe("
|
|
22
|
+
describe("deriveEntityTableMeta — options.source (#1210)", () => {
|
|
23
23
|
test("defaults to managed when omitted", () => {
|
|
24
|
-
expect(
|
|
24
|
+
expect(deriveEntityTableMeta("source-probe", entity).source).toBe("managed");
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
test("options.source: 'unmanaged' changes only source, columns + piiSubjectFields stay identical", () => {
|
|
28
|
-
const managed =
|
|
29
|
-
const unmanaged =
|
|
28
|
+
const managed = deriveEntityTableMeta("source-probe", entity);
|
|
29
|
+
const unmanaged = deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" });
|
|
30
30
|
|
|
31
31
|
expect(managed.source).toBe("managed");
|
|
32
32
|
expect(unmanaged.source).toBe("unmanaged");
|
|
@@ -37,9 +37,9 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test("flipping an existing table's meta to unmanaged produces an empty migration diff", () => {
|
|
40
|
-
const prevSnapshot = snapshotFromMetas([
|
|
40
|
+
const prevSnapshot = snapshotFromMetas([deriveEntityTableMeta("source-probe", entity)]);
|
|
41
41
|
const nextSnapshot = snapshotFromMetas([
|
|
42
|
-
|
|
42
|
+
deriveEntityTableMeta("source-probe", entity, { source: "unmanaged" }),
|
|
43
43
|
]);
|
|
44
44
|
|
|
45
45
|
const diff = diffSnapshots(prevSnapshot, nextSnapshot);
|
|
@@ -47,4 +47,39 @@ describe("buildEntityTableMeta — options.source (#1210)", () => {
|
|
|
47
47
|
expect(diff.droppedTables).toEqual([]);
|
|
48
48
|
expect(diff.changedTables).toEqual([]);
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
test("deprecated buildEntityTableMeta alias still works", async () => {
|
|
52
|
+
const { buildEntityTableMeta } = await import("../entity-table-meta");
|
|
53
|
+
expect(buildEntityTableMeta("source-probe", entity).source).toBe("managed");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("unmanaged builders reject read_ prefix (#1208)", () => {
|
|
58
|
+
test("deriveEntityTableMeta(..., { source: unmanaged }) with read_ table throws", () => {
|
|
59
|
+
const readEntity = createEntity({
|
|
60
|
+
table: "read_source_probe",
|
|
61
|
+
fields: { userId: createTextField({ required: true }) },
|
|
62
|
+
});
|
|
63
|
+
expect(() =>
|
|
64
|
+
deriveEntityTableMeta("source-probe", readEntity, { source: "unmanaged" }),
|
|
65
|
+
).toThrow(/the "read_" prefix is reserved/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("default toTableName (read_*) + unmanaged throws", () => {
|
|
69
|
+
const noTable = createEntity({
|
|
70
|
+
fields: { userId: createTextField({ required: true }) },
|
|
71
|
+
});
|
|
72
|
+
expect(() => deriveEntityTableMeta("source-probe", noTable, { source: "unmanaged" })).toThrow(
|
|
73
|
+
/the "read_" prefix is reserved/,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("defineUnmanagedTable with read_ tableName throws", () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
defineUnmanagedTable({
|
|
80
|
+
tableName: "read_oops",
|
|
81
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
82
|
+
}),
|
|
83
|
+
).toThrow(/the "read_" prefix is reserved/);
|
|
84
|
+
});
|
|
50
85
|
});
|
|
@@ -34,6 +34,10 @@ describe("splitSqlStatements", () => {
|
|
|
34
34
|
expect(splitSqlStatements(sql)).toEqual(['CREATE TABLE "a" ("id" uuid);']);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test("block comment leaves a space so adjacent tokens do not fuse", () => {
|
|
38
|
+
expect(splitSqlStatements("SELECT a/*x*/AS b;")).toEqual(["SELECT a AS b;"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
37
41
|
test("a block-comment opener inside a line comment does not swallow the next statement", () => {
|
|
38
42
|
const sql = `
|
|
39
43
|
-- note: see /* details below
|
|
@@ -69,7 +73,17 @@ describe("splitSqlStatements", () => {
|
|
|
69
73
|
|
|
70
74
|
test("throws fail-loud on an unterminated block comment instead of silently dropping statements", () => {
|
|
71
75
|
const sql = `/* oops\nCREATE TABLE "a" ("id" uuid);`;
|
|
72
|
-
expect(() => splitSqlStatements(sql)).toThrow();
|
|
76
|
+
expect(() => splitSqlStatements(sql)).toThrow(/unterminated blockComment/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("throws fail-loud on an unterminated single-quoted string", () => {
|
|
80
|
+
const sql = `INSERT INTO "a" ("v") VALUES ('oops;`;
|
|
81
|
+
expect(() => splitSqlStatements(sql)).toThrow(/unterminated singleQuote/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("throws fail-loud on an unterminated double-quoted identifier", () => {
|
|
85
|
+
const sql = `CREATE TABLE "weird;`;
|
|
86
|
+
expect(() => splitSqlStatements(sql)).toThrow(/unterminated doubleQuote/);
|
|
73
87
|
});
|
|
74
88
|
|
|
75
89
|
test("a trailing line comment without a newline terminates cleanly", () => {
|
|
@@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createEntity } from "../../engine/factories";
|
|
3
3
|
import { sql } from "../dialect";
|
|
4
4
|
import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
|
|
5
|
-
import {
|
|
5
|
+
import { deriveEntityTableMeta } from "../entity-table-meta";
|
|
6
6
|
import { asEntityTableMeta } from "../query";
|
|
7
7
|
import { buildEntityTable } from "../table-builder";
|
|
8
8
|
|
|
9
9
|
// Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
|
|
10
|
-
// KUMIKO_META_SYMBOL) und
|
|
10
|
+
// KUMIKO_META_SYMBOL) und deriveEntityTableMeta (Migrations-Pfad) müssen
|
|
11
11
|
// für dieselbe EntityDefinition identische Spalten + Indexes produzieren.
|
|
12
12
|
// Drift hier = Migration und Prod-Tabelle (bzw. collectTableMetas-Output)
|
|
13
13
|
// gehen auseinander — gefunden als #255-Follow-up: select/number/bigInt
|
|
@@ -33,9 +33,9 @@ function byName<T extends { name: string }>(items: readonly T[]): readonly T[] {
|
|
|
33
33
|
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
describe("buildEntityTable ↔
|
|
36
|
+
describe("buildEntityTable ↔ deriveEntityTableMeta lock-step", () => {
|
|
37
37
|
const fromBuilder = asEntityTableMeta(buildEntityTable("lockstepProbe", entityWithDefaults));
|
|
38
|
-
const fromMeta =
|
|
38
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbe", entityWithDefaults);
|
|
39
39
|
|
|
40
40
|
test("builder table carries an EntityTableMeta", () => {
|
|
41
41
|
expect(fromBuilder).toBeDefined();
|
|
@@ -92,7 +92,7 @@ describe("lock-step — softDelete + explizite Indexes", () => {
|
|
|
92
92
|
const fromBuilder = asEntityTableMeta(
|
|
93
93
|
buildEntityTable("lockstepProbeSd", entityWithSoftDeleteAndIndexes),
|
|
94
94
|
);
|
|
95
|
-
const fromMeta =
|
|
95
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
|
|
96
96
|
|
|
97
97
|
test("identical columns inkl. softDelete-Spalten", () => {
|
|
98
98
|
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
@@ -125,7 +125,7 @@ describe("lock-step — lookupable / blind-index (#818)", () => {
|
|
|
125
125
|
const fromBuilder = asEntityTableMeta(
|
|
126
126
|
buildEntityTable("lockstepProbeBidx", entityWithLookupable),
|
|
127
127
|
);
|
|
128
|
-
const fromMeta =
|
|
128
|
+
const fromMeta = deriveEntityTableMeta("lockstepProbeBidx", entityWithLookupable);
|
|
129
129
|
|
|
130
130
|
test("identical columns inkl. nullable bidx-Spalte", () => {
|
|
131
131
|
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
@@ -3,7 +3,7 @@ import { createEntity, createTextField } from "../../engine";
|
|
|
3
3
|
import { testTenantId } from "../../stack";
|
|
4
4
|
import type { DbRunner } from "../connection";
|
|
5
5
|
import type { TableColumns } from "../dialect";
|
|
6
|
-
import {
|
|
6
|
+
import { deriveEntityTableMeta } from "../entity-table-meta";
|
|
7
7
|
import { buildEntityTable } from "../table-builder";
|
|
8
8
|
import { createTenantDb } from "../tenant-db";
|
|
9
9
|
|
|
@@ -123,16 +123,16 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
// Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
|
|
126
|
-
// unmanaged direct-write stores (
|
|
126
|
+
// unmanaged direct-write stores (deriveEntityTableMeta, e.g. userSessionTable,
|
|
127
127
|
// apiTokenTable) store tenantId as a snake_case column-meta entry, not a
|
|
128
128
|
// direct `table.tenantId` property — a naive property check silently treated
|
|
129
129
|
// them as tenant-less and skipped the WHERE-scope entirely.
|
|
130
|
-
describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (
|
|
130
|
+
describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (deriveEntityTableMeta)", () => {
|
|
131
131
|
const unmanagedEntity = createEntity({
|
|
132
132
|
table: "merge_meta_items",
|
|
133
133
|
fields: { tenantId: createTextField({ required: true }), name: createTextField() },
|
|
134
134
|
});
|
|
135
|
-
const unmanagedTable =
|
|
135
|
+
const unmanagedTable = deriveEntityTableMeta("merge-meta-item", unmanagedEntity);
|
|
136
136
|
|
|
137
137
|
test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
|
|
138
138
|
const captured: Captured[] = [];
|
|
@@ -9,7 +9,7 @@ import type { FeatureDefinition } from "../engine/types";
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import {
|
|
11
11
|
assertBackingTableSuperset,
|
|
12
|
-
|
|
12
|
+
deriveEntityTableMeta,
|
|
13
13
|
type EntityTableMeta,
|
|
14
14
|
} from "./entity-table-meta";
|
|
15
15
|
import { enumerateFeatureTableSources } from "./feature-table-sources";
|
|
@@ -36,10 +36,10 @@ export function collectTableMetas(
|
|
|
36
36
|
const byName = new Map<string, { meta: EntityTableMeta; origin: string }>();
|
|
37
37
|
|
|
38
38
|
// Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
|
|
39
|
-
// Verhalten (gleiche Reihenfolge, gleiche
|
|
39
|
+
// Verhalten (gleiche Reihenfolge, gleiche deriveEntityTableMeta-Optionen).
|
|
40
40
|
for (const feature of features) {
|
|
41
41
|
for (const [name, ent] of Object.entries(feature.entities ?? {})) {
|
|
42
|
-
const fieldMeta =
|
|
42
|
+
const fieldMeta = deriveEntityTableMeta(name, ent, { relations: feature.relations[name] });
|
|
43
43
|
// Backing table wins: it's the physical DDL truth for ride-along columns/
|
|
44
44
|
// indexes the field-DSL can't express (secrets' envelope). Validated as a
|
|
45
45
|
// superset of the field-derived meta so a field/table disagreement throws.
|
|
@@ -1,22 +1,20 @@
|
|
|
1
|
-
// EntityTableMeta — plain-data
|
|
2
|
-
// Single source of truth
|
|
1
|
+
// EntityTableMeta — plain-data schema meta for a read-model table.
|
|
2
|
+
// Single source of truth instead of a married drizzle pgTable builder.
|
|
3
3
|
//
|
|
4
|
-
// Phase 3a (Drizzle-Replacement Plan):
|
|
5
|
-
//
|
|
6
|
-
// `entityTableMetaToEntityTable`)
|
|
7
|
-
// umstellt.
|
|
4
|
+
// Phase 3a (Drizzle-Replacement Plan): type + generator exist in parallel
|
|
5
|
+
// with buildEntityTable. Consumers stay on EntityTable (via adapter
|
|
6
|
+
// `entityTableMetaToEntityTable`) until Phase 4 moves the query API to Bun.sql.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
// 1. **Managed** — EntityDefinition via
|
|
11
|
-
// Standard
|
|
12
|
-
// modified_at, inserted_by_id, modified_by_id,
|
|
13
|
-
//
|
|
14
|
-
// 2. **Unmanaged** — defineUnmanagedTable(input)
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
|
|
8
|
+
// Two sources:
|
|
9
|
+
// 1. **Managed** — EntityDefinition via deriveEntityTableMeta(name, entity).
|
|
10
|
+
// Standard path with base columns (id, tenant_id, version, inserted_at,
|
|
11
|
+
// modified_at, inserted_by_id, modified_by_id, optional softDelete cols),
|
|
12
|
+
// automatic tenant_id index, audit-capable. Defaults to source: "managed".
|
|
13
|
+
// 2. **Unmanaged** — defineUnmanagedTable(input), or
|
|
14
|
+
// deriveEntityTableMeta(..., { source: "unmanaged" }) when you still want
|
|
15
|
+
// entity-shaped base columns. Escape hatch: no forced audit trail for the
|
|
16
|
+
// hand-built path; app author owns tenant scoping / versioning. Prefer a
|
|
17
|
+
// `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
|
|
20
18
|
|
|
21
19
|
import { collectPiiSubjectFields } from "../crypto";
|
|
22
20
|
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
@@ -232,12 +230,25 @@ export function resolveTableName(
|
|
|
232
230
|
return `${featureName}_${baseName}`;
|
|
233
231
|
}
|
|
234
232
|
|
|
235
|
-
|
|
233
|
+
/**
|
|
234
|
+
* Derive EntityTableMeta from an EntityDefinition (base columns + field DDL).
|
|
235
|
+
* Defaults to `source: "managed"` (rebuildable projection). For direct-write
|
|
236
|
+
* stores pass `{ source: "unmanaged" }` and use a non-`read_` table name
|
|
237
|
+
* (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
|
|
238
|
+
*
|
|
239
|
+
* Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
|
|
240
|
+
* escape hatch (#1208).
|
|
241
|
+
*/
|
|
242
|
+
export function deriveEntityTableMeta(
|
|
236
243
|
entityName: string,
|
|
237
244
|
entity: EntityDefinition,
|
|
238
245
|
options?: BuildEntityTableMetaOptions,
|
|
239
246
|
): EntityTableMeta {
|
|
240
247
|
const tableName = resolveTableName(entityName, entity, options?.featureName);
|
|
248
|
+
const source = options?.source ?? "managed";
|
|
249
|
+
if (source === "unmanaged") {
|
|
250
|
+
assertUnmanagedTableName(tableName, "deriveEntityTableMeta");
|
|
251
|
+
}
|
|
241
252
|
const idType = entity.idType ?? "uuid";
|
|
242
253
|
|
|
243
254
|
// Base-columns first, then user-fields. User-fields with the same
|
|
@@ -349,24 +360,14 @@ export function buildEntityTableMeta(
|
|
|
349
360
|
tableName,
|
|
350
361
|
columns,
|
|
351
362
|
indexes,
|
|
352
|
-
source
|
|
363
|
+
source,
|
|
353
364
|
...(piiSubjectFields.length > 0 && { piiSubjectFields }),
|
|
354
365
|
};
|
|
355
366
|
}
|
|
356
367
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
//
|
|
361
|
-
// **Vorsicht-vor-Use:** wenn du das hier benutzt, gibst du das Standard-
|
|
362
|
-
// Audit-Pattern auf. Begründe im Code WARUM (child-projection ohne tenant-
|
|
363
|
-
// scope, aggregate-id-PK ohne DEFAULT, append-only-log mit serial PK,
|
|
364
|
-
// performance-critical hot-path ohne version-check, …). Reviewer sollten
|
|
365
|
-
// jede neue defineUnmanagedTable-Stelle prüfen.
|
|
366
|
-
//
|
|
367
|
-
// Heutige use-cases im framework:
|
|
368
|
-
// - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
|
|
369
|
-
// - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
|
|
368
|
+
/** @deprecated Use {@link deriveEntityTableMeta} — the old name read as an unmanaged escape hatch (#1208). */
|
|
369
|
+
export const buildEntityTableMeta = deriveEntityTableMeta;
|
|
370
|
+
|
|
370
371
|
function sqlExpressionText(where: unknown): string | undefined {
|
|
371
372
|
if (
|
|
372
373
|
typeof where === "object" &&
|
|
@@ -420,7 +421,12 @@ function columnsByNameMeta(meta: EntityTableMeta): Map<string, ColumnMeta> {
|
|
|
420
421
|
return m;
|
|
421
422
|
}
|
|
422
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
426
|
+
* Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
|
|
427
|
+
*/
|
|
423
428
|
export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMeta {
|
|
429
|
+
assertUnmanagedTableName(input.tableName, "defineUnmanagedTable");
|
|
424
430
|
return {
|
|
425
431
|
tableName: input.tableName,
|
|
426
432
|
columns: input.columns,
|
|
@@ -431,3 +437,14 @@ export function defineUnmanagedTable(input: UnmanagedTableInput): EntityTableMet
|
|
|
431
437
|
source: "unmanaged",
|
|
432
438
|
};
|
|
433
439
|
}
|
|
440
|
+
|
|
441
|
+
function assertUnmanagedTableName(tableName: string, via: string): void {
|
|
442
|
+
if (tableName.startsWith("read_")) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`${via}("${tableName}"): the "read_" prefix is reserved for managed ` +
|
|
445
|
+
`r.entity()/r.projection() tables. Unmanaged direct-write stores need a ` +
|
|
446
|
+
`distinct name (convention: "store_${tableName.slice("read_".length)}"). ` +
|
|
447
|
+
`See #1208/#1220.`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -59,7 +59,11 @@ export type {
|
|
|
59
59
|
PgType,
|
|
60
60
|
UnmanagedTableInput,
|
|
61
61
|
} from "./entity-table-meta";
|
|
62
|
-
export {
|
|
62
|
+
export {
|
|
63
|
+
buildEntityTableMeta,
|
|
64
|
+
defineUnmanagedTable,
|
|
65
|
+
deriveEntityTableMeta,
|
|
66
|
+
} from "./entity-table-meta";
|
|
63
67
|
export type {
|
|
64
68
|
EntityLifecycleVerb,
|
|
65
69
|
EventStoreExecutor,
|
package/src/db/migrate-runner.ts
CHANGED
|
@@ -74,16 +74,7 @@ CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
|
|
|
74
74
|
)
|
|
75
75
|
`.trim();
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
// `text.split(";")` breaks the moment a `--` line comment or `/* */` block
|
|
79
|
-
// comment contains a semicolon (#1542) — it splits mid-comment before the
|
|
80
|
-
// comment is ever stripped. This scans char-by-char tracking whether we're
|
|
81
|
-
// inside a line comment, block comment, single-quoted string, or
|
|
82
|
-
// double-quoted identifier, so `;` only ends a statement in plain SQL text;
|
|
83
|
-
// comments are dropped, quoted/identifier content (incl. `''`/`""` escapes)
|
|
84
|
-
// is kept verbatim. Does not handle dollar-quoted (`$$...$$`) bodies — none
|
|
85
|
-
// of this repo's checked-in migrations use them; add that state if one ever
|
|
86
|
-
// does.
|
|
77
|
+
// Plain `;`-split breaks on `;` inside comments/string literals (#1542).
|
|
87
78
|
type SqlScanState = "normal" | "lineComment" | "blockComment" | "singleQuote" | "doubleQuote";
|
|
88
79
|
|
|
89
80
|
export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
@@ -106,6 +97,8 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
106
97
|
if (ch === "*" && next === "/") {
|
|
107
98
|
state = "normal";
|
|
108
99
|
i++;
|
|
100
|
+
// Keep a space so `a/*x*/AS` does not become `aAS`.
|
|
101
|
+
current += " ";
|
|
109
102
|
}
|
|
110
103
|
continue;
|
|
111
104
|
}
|
|
@@ -163,6 +156,8 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
163
156
|
current += ch;
|
|
164
157
|
}
|
|
165
158
|
if (state === "blockComment" || state === "singleQuote" || state === "doubleQuote") {
|
|
159
|
+
// Does not track dollar-quoted (`$$...$$`) bodies — none of this repo's
|
|
160
|
+
// migrations use them; add that state if one ever does.
|
|
166
161
|
throw new Error(
|
|
167
162
|
`splitSqlStatements: unterminated ${state} — migration SQL is malformed, refusing to split`,
|
|
168
163
|
);
|
package/src/db/table-builder.ts
CHANGED
|
@@ -526,7 +526,7 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
526
526
|
}
|
|
527
527
|
}
|
|
528
528
|
// lookupable-Felder: Index auf der bidx-Spalte (lock-step mit
|
|
529
|
-
//
|
|
529
|
+
// deriveEntityTableMeta).
|
|
530
530
|
const bidxFieldByField = new Map<string, string>();
|
|
531
531
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
532
532
|
if (field.type !== "text" || field.lookupable !== true) continue;
|
|
@@ -558,7 +558,7 @@ export function buildEntityTable<E extends EntityDefinition>(
|
|
|
558
558
|
}
|
|
559
559
|
indexes[indexName] = chain;
|
|
560
560
|
// Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
|
|
561
|
-
// (lock-step mit
|
|
561
|
+
// (lock-step mit deriveEntityTableMeta).
|
|
562
562
|
if (def.unique === true && def.where === undefined) {
|
|
563
563
|
const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
|
|
564
564
|
if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -30,7 +30,7 @@ function tableNameOf(table: Table): string {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
|
|
33
|
-
// or a plain
|
|
33
|
+
// or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
|
|
34
34
|
// `table.tenantId` property read — the latter only exists on branded EntityTables
|
|
35
35
|
// and silently returned false (no tenant filter!) for plain EntityTableMeta
|
|
36
36
|
// tables like unmanaged direct-write stores, e.g. userSessionTable.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import {
|
|
10
|
-
buildEntityTableMeta,
|
|
11
10
|
defineUnmanagedTable,
|
|
11
|
+
deriveEntityTableMeta,
|
|
12
12
|
resolveTableName,
|
|
13
13
|
} from "../../db/entity-table-meta";
|
|
14
14
|
import { defineFeature } from "../define-feature";
|
|
@@ -67,7 +67,7 @@ describe("r.storeTable — declaration", () => {
|
|
|
67
67
|
table: "rt_probe_managed",
|
|
68
68
|
fields: { name: createTextField() },
|
|
69
69
|
});
|
|
70
|
-
const managedMeta =
|
|
70
|
+
const managedMeta = deriveEntityTableMeta("rt-probe-managed", managedEntity);
|
|
71
71
|
expect(() =>
|
|
72
72
|
defineFeature("probe", (r) => {
|
|
73
73
|
r.storeTable(managedMeta, { reason: "test" });
|
|
@@ -75,14 +75,11 @@ describe("r.storeTable — declaration", () => {
|
|
|
75
75
|
).toThrow(/requires source: "unmanaged"/);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
test("rejects a table name with the reserved read_ prefix (#1220)", () => {
|
|
79
|
-
const readPrefixed = defineUnmanagedTable({
|
|
80
|
-
tableName: "read_rt_probe",
|
|
81
|
-
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
82
|
-
});
|
|
78
|
+
test("rejects a table name with the reserved read_ prefix (#1220/#1208)", () => {
|
|
83
79
|
expect(() =>
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
defineUnmanagedTable({
|
|
81
|
+
tableName: "read_rt_probe",
|
|
82
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
86
83
|
}),
|
|
87
84
|
).toThrow(/the "read_" prefix is reserved/);
|
|
88
85
|
});
|
|
@@ -195,9 +192,9 @@ describe("createRegistry — store tables with PII-annotated fields (#820)", ()
|
|
|
195
192
|
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
196
193
|
},
|
|
197
194
|
});
|
|
198
|
-
const piiMeta =
|
|
195
|
+
const piiMeta = deriveEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
|
|
199
196
|
|
|
200
|
-
test("
|
|
197
|
+
test("deriveEntityTableMeta records the subject-annotated field names", () => {
|
|
201
198
|
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
202
199
|
});
|
|
203
200
|
|
package/src/engine/constants.ts
CHANGED
|
@@ -103,10 +103,6 @@ export function tenantChannel(tenantId: TenantId): string {
|
|
|
103
103
|
return `tenant:${tenantId}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Access-invalidation channel key for a single user's live streams. Both
|
|
107
|
-
// the subscribe side (dispatch-stream.ts) and the publish side (session-
|
|
108
|
-
// revoke / tenant-membership consumers, issue #1559/#1560) must derive the
|
|
109
|
-
// key through this helper — never build the string inline on either side.
|
|
110
106
|
export function userAccessChannel(userId: string): string {
|
|
111
107
|
return `user:${userId}:access`;
|
|
112
108
|
}
|
|
@@ -100,7 +100,7 @@ export function extractStoreTable(
|
|
|
100
100
|
sourceFile: SourceFile,
|
|
101
101
|
): ExtractOutput<never> {
|
|
102
102
|
// The meta argument is always a factory call (defineUnmanagedTable /
|
|
103
|
-
//
|
|
103
|
+
// deriveEntityTableMeta) or a captured identifier — never an inline literal,
|
|
104
104
|
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
105
|
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
106
|
return fail(
|
|
@@ -454,7 +454,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
454
454
|
throw new Error(
|
|
455
455
|
`[Feature ${name}] r.storeTable("${tableName}") was given an EntityTableMeta with ` +
|
|
456
456
|
`source: "${meta.source}". r.storeTable() requires source: "unmanaged" (via ` +
|
|
457
|
-
`defineUnmanagedTable(), or
|
|
457
|
+
`defineUnmanagedTable(), or deriveEntityTableMeta(..., { source: "unmanaged" })) — ` +
|
|
458
458
|
`otherwise the migration generator will treat schema drift on this table as safe ` +
|
|
459
459
|
`to DROP+rebuild, wiping any direct-write data.`,
|
|
460
460
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
|
-
import { assertBackingTableSuperset,
|
|
2
|
+
import { assertBackingTableSuperset, deriveEntityTableMeta } from "../db/entity-table-meta";
|
|
3
3
|
import { asEntityTableMeta } from "../db/query";
|
|
4
4
|
import { buildEntityTable } from "../db/table-builder";
|
|
5
5
|
import { type QnType, qualifyEntityName } from "./qualified-name";
|
|
@@ -143,7 +143,7 @@ function resolveBackingTable(
|
|
|
143
143
|
"EntityTableMeta — build it via table() / buildEntityTable.",
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
|
-
assertBackingTableSuperset(entityName,
|
|
146
|
+
assertBackingTableSuperset(entityName, deriveEntityTableMeta(entityName, entity), tableMeta);
|
|
147
147
|
return backingTable as ProjectionDefinition["table"];
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -71,7 +71,7 @@ function resolveTableNameFromStep(table: unknown): string {
|
|
|
71
71
|
const metaName = (meta as Record<string, unknown>)["tableName"];
|
|
72
72
|
if (typeof metaName === "string") return metaName;
|
|
73
73
|
}
|
|
74
|
-
// Plain meta (
|
|
74
|
+
// Plain meta (deriveEntityTableMeta / defineUnmanagedTable — no handle-spread).
|
|
75
75
|
if (
|
|
76
76
|
"source" in table &&
|
|
77
77
|
"tableName" in table &&
|
|
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
|
-
import {
|
|
11
|
+
import { deriveEntityTableMeta } from "../../db/entity-table-meta";
|
|
12
12
|
import { generateMigration, writeSnapshotJson } from "../../db/migrate-generator";
|
|
13
13
|
import {
|
|
14
14
|
baselineMigrations,
|
|
@@ -200,7 +200,7 @@ describe("kumiko-drift end-to-end (generate → apply → gate)", () => {
|
|
|
200
200
|
table: "kdrift_gen",
|
|
201
201
|
fields: { name: createTextField({ required: true }) },
|
|
202
202
|
});
|
|
203
|
-
const meta =
|
|
203
|
+
const meta = deriveEntityTableMeta("kdriftGen", entity);
|
|
204
204
|
const result = generateMigration({
|
|
205
205
|
metas: [meta],
|
|
206
206
|
prevSnapshot: null,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// rebuild-Marker → mappt Tabellen auf Projektionen → rebuildProjection).
|
|
4
4
|
//
|
|
5
5
|
// Drizzle-frei: der Tabellen-Name kommt aus dem kumiko-Symbol das
|
|
6
|
-
// buildEntityTable/
|
|
6
|
+
// buildEntityTable/deriveEntityTableMeta an die Table-Definition hängt.
|
|
7
7
|
|
|
8
8
|
import { extractTableName } from "../db";
|
|
9
9
|
import type { Registry } from "../engine/types/feature";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import type { SseBroker } from "../../api/sse-broker";
|
|
3
4
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
5
|
import type { TenantId } from "../../engine/types/identifiers";
|
|
5
6
|
import { createSecret } from "../../secrets/types";
|
|
@@ -292,6 +293,66 @@ describe("dispatcher.stream", () => {
|
|
|
292
293
|
).rejects.toMatchObject({ code: "access_denied" });
|
|
293
294
|
});
|
|
294
295
|
|
|
296
|
+
test("access invalidation mid-stream rejects with AccessDeniedError and unsubscribes", async () => {
|
|
297
|
+
let onInvalidate: (() => void) | undefined;
|
|
298
|
+
let unsubscribeCalls = 0;
|
|
299
|
+
const broker: SseBroker = {
|
|
300
|
+
addClient() {
|
|
301
|
+
return "c";
|
|
302
|
+
},
|
|
303
|
+
removeClient() {},
|
|
304
|
+
pushToChannel() {},
|
|
305
|
+
getClientCount() {
|
|
306
|
+
return 0;
|
|
307
|
+
},
|
|
308
|
+
getTotalClientCount() {
|
|
309
|
+
return 0;
|
|
310
|
+
},
|
|
311
|
+
subscribeAccessInvalidation(_userId, cb) {
|
|
312
|
+
onInvalidate = cb;
|
|
313
|
+
return () => {
|
|
314
|
+
unsubscribeCalls++;
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
publishAccessInvalidation() {},
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
let releaseHang: (() => void) | undefined;
|
|
321
|
+
const hang = new Promise<void>((resolve) => {
|
|
322
|
+
releaseHang = resolve;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const revokeFeature = defineFeature("revoke", (r) => {
|
|
326
|
+
r.streamHandler(
|
|
327
|
+
"tail",
|
|
328
|
+
z.object({}),
|
|
329
|
+
async function* () {
|
|
330
|
+
yield { i: 0 };
|
|
331
|
+
await hang;
|
|
332
|
+
yield { i: 1 };
|
|
333
|
+
releaseHang?.();
|
|
334
|
+
},
|
|
335
|
+
{ access: { roles: ["Admin"] } },
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const user = createTestUser({ roles: ["Admin"] });
|
|
340
|
+
const dispatcher = createDispatcher(createRegistry([revokeFeature]), {}, { sseBroker: broker });
|
|
341
|
+
const gen = dispatcher.stream("revoke:stream:tail", {}, user);
|
|
342
|
+
const first = await gen.next();
|
|
343
|
+
expect(first.value).toEqual({ i: 0 });
|
|
344
|
+
expect(onInvalidate).toBeDefined();
|
|
345
|
+
// Start the idle second pull, then revoke — mirrors heartbeat-only SSE
|
|
346
|
+
// streams that must die without waiting for the next chunk (#1563).
|
|
347
|
+
const second = gen.next();
|
|
348
|
+
onInvalidate?.();
|
|
349
|
+
await expect(second).rejects.toMatchObject({
|
|
350
|
+
code: "access_denied",
|
|
351
|
+
message: expect.stringContaining("access revoked mid-stream"),
|
|
352
|
+
});
|
|
353
|
+
expect(unsubscribeCalls).toBe(1);
|
|
354
|
+
});
|
|
355
|
+
|
|
295
356
|
test("throws for unknown stream handler", async () => {
|
|
296
357
|
const dispatcher = createTestDispatcher();
|
|
297
358
|
|
|
@@ -53,10 +53,8 @@ async function* executeStreamInner(
|
|
|
53
53
|
throw validationErrorFromZod(parsed.error);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// leave revoked sessions open indefinitely (fw#1563). Race each pull
|
|
59
|
-
// against an invalidated Deferred instead.
|
|
56
|
+
// Idle (heartbeat-only) streams must also cut on access revoke — race each
|
|
57
|
+
// pull against an invalidated Deferred instead of a post-chunk boolean.
|
|
60
58
|
let resolveInvalidated: (() => void) | undefined;
|
|
61
59
|
const invalidated = new Promise<void>((resolve) => {
|
|
62
60
|
resolveInvalidated = resolve;
|
|
@@ -66,6 +64,11 @@ async function* executeStreamInner(
|
|
|
66
64
|
});
|
|
67
65
|
|
|
68
66
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
67
|
+
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
68
|
+
// Awaiting `iterator.return()` in that state deadlocks async generators in
|
|
69
|
+
// Bun (overlapping next+return). Track abandonment so finally skips the
|
|
70
|
+
// await; close is fire-and-forget instead (#1563).
|
|
71
|
+
let abandonedForInvalidation = false;
|
|
69
72
|
try {
|
|
70
73
|
const handlerContext = buildHandlerContext(ctx, type, user);
|
|
71
74
|
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
@@ -78,6 +81,9 @@ async function* executeStreamInner(
|
|
|
78
81
|
invalidated.then(() => ({ kind: "invalidated" as const })),
|
|
79
82
|
]);
|
|
80
83
|
if (outcome.kind === "invalidated") {
|
|
84
|
+
abandonedForInvalidation = true;
|
|
85
|
+
void nextPull.catch(() => {});
|
|
86
|
+
void iterator.return?.(undefined)?.then(undefined, () => {});
|
|
81
87
|
throw new AccessDeniedError({
|
|
82
88
|
message: `access revoked mid-stream for ${type}`,
|
|
83
89
|
details: { handler: type },
|
|
@@ -90,11 +96,12 @@ async function* executeStreamInner(
|
|
|
90
96
|
}
|
|
91
97
|
} finally {
|
|
92
98
|
unsubscribeAccessInvalidation?.();
|
|
93
|
-
// Consumer break /
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
99
|
+
// Consumer break / throw — always close the handler generator so its
|
|
100
|
+
// finally (cleanup) runs (for-await would do this). Do NOT swallow
|
|
101
|
+
// return() errors — close-time cleanup failures must surface to
|
|
102
|
+
// runStreamInstrumented (#1543). Skip the await after access-revoke
|
|
103
|
+
// abandonment (overlapping next+return deadlocks — see above).
|
|
104
|
+
if (iterator !== undefined && !abandonedForInvalidation) {
|
|
98
105
|
await iterator.return?.(undefined);
|
|
99
106
|
}
|
|
100
107
|
}
|