@cosmicdrift/kumiko-framework 0.165.1 → 0.165.3
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/__tests__/sse-broker.test.ts +27 -18
- package/src/api/routes.ts +3 -3
- package/src/api/sse-broker.ts +12 -13
- package/src/bun-db/query.ts +2 -2
- package/src/crypto/index.ts +1 -0
- package/src/crypto/subject-resolver.ts +15 -0
- 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 +19 -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 +18 -11
- package/src/db/table-builder.ts +2 -2
- package/src/db/tenant-db.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
- package/src/engine/__tests__/store-table.test.ts +8 -11
- package/src/engine/boot-validator/pii-retention.ts +18 -8
- package/src/engine/constants.ts +0 -4
- package/src/engine/feature-ast/extractors/round5.ts +1 -1
- package/src/engine/feature-changelog.ts +93 -0
- package/src/engine/feature-manifest.ts +4 -0
- package/src/engine/feature-ui-extensions.ts +1 -1
- package/src/engine/index.ts +10 -0
- package/src/engine/registry-state.ts +2 -2
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-runner.ts +31 -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 +22 -12
- package/src/pipeline/system-hooks.ts +54 -7
- package/src/schema-cli.ts +2 -3
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
- package/src/search/index.ts +1 -0
- package/src/search/purge-subject.ts +135 -0
- package/src/testing/__tests__/wait-for.test.ts +8 -4
- package/src/testing/wait-for.ts +10 -5
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,22 +74,14 @@ 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[] {
|
|
90
81
|
const statements: string[] = [];
|
|
91
82
|
let current = "";
|
|
92
83
|
let state: SqlScanState = "normal";
|
|
84
|
+
let blockCommentDepth = 0;
|
|
93
85
|
|
|
94
86
|
for (let i = 0; i < sqlText.length; i++) {
|
|
95
87
|
const ch = sqlText.charAt(i);
|
|
@@ -103,9 +95,21 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
103
95
|
continue;
|
|
104
96
|
}
|
|
105
97
|
if (state === "blockComment") {
|
|
98
|
+
// Postgres nests block comments — track depth so the first `*/` does
|
|
99
|
+
// not leave trailing comment text in the statement.
|
|
100
|
+
if (ch === "/" && next === "*") {
|
|
101
|
+
blockCommentDepth++;
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
106
105
|
if (ch === "*" && next === "/") {
|
|
107
|
-
state = "normal";
|
|
108
106
|
i++;
|
|
107
|
+
blockCommentDepth--;
|
|
108
|
+
if (blockCommentDepth === 0) {
|
|
109
|
+
state = "normal";
|
|
110
|
+
// Keep a space so `a/*x*/AS` does not become `aAS`.
|
|
111
|
+
current += " ";
|
|
112
|
+
}
|
|
109
113
|
}
|
|
110
114
|
continue;
|
|
111
115
|
}
|
|
@@ -142,6 +146,7 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
142
146
|
}
|
|
143
147
|
if (ch === "/" && next === "*") {
|
|
144
148
|
state = "blockComment";
|
|
149
|
+
blockCommentDepth = 1;
|
|
145
150
|
i++;
|
|
146
151
|
continue;
|
|
147
152
|
}
|
|
@@ -163,6 +168,8 @@ export function splitSqlStatements(sqlText: string): readonly string[] {
|
|
|
163
168
|
current += ch;
|
|
164
169
|
}
|
|
165
170
|
if (state === "blockComment" || state === "singleQuote" || state === "doubleQuote") {
|
|
171
|
+
// Does not track dollar-quoted (`$$...$$`) bodies — none of this repo's
|
|
172
|
+
// migrations use them; add that state if one ever does.
|
|
166
173
|
throw new Error(
|
|
167
174
|
`splitSqlStatements: unterminated ${state} — migration SQL is malformed, refusing to split`,
|
|
168
175
|
);
|
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.
|
|
@@ -680,7 +680,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
680
680
|
expect(() => validateBoot([feature])).toThrow(/only apply to text fields/);
|
|
681
681
|
});
|
|
682
682
|
|
|
683
|
-
test("searchable combined with a subject annotation
|
|
683
|
+
test("searchable combined with a subject annotation passes (#1610)", () => {
|
|
684
684
|
const feature = defineFeature("test", (r) => {
|
|
685
685
|
r.entity(
|
|
686
686
|
"user",
|
|
@@ -691,7 +691,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
691
691
|
}),
|
|
692
692
|
);
|
|
693
693
|
});
|
|
694
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
694
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
695
695
|
});
|
|
696
696
|
|
|
697
697
|
test("sortable combined with a subject annotation throws", () => {
|
|
@@ -705,7 +705,25 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
705
705
|
}),
|
|
706
706
|
);
|
|
707
707
|
});
|
|
708
|
-
expect(() => validateBoot([feature])).toThrow(/sortable
|
|
708
|
+
expect(() => validateBoot([feature])).toThrow(/sortable/);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("searchable combined with sensitive throws (#1610)", () => {
|
|
712
|
+
const feature = defineFeature("test", (r) => {
|
|
713
|
+
r.entity(
|
|
714
|
+
"user",
|
|
715
|
+
createEntity({
|
|
716
|
+
fields: {
|
|
717
|
+
passwordHash: createTextField({
|
|
718
|
+
pii: true,
|
|
719
|
+
sensitive: true,
|
|
720
|
+
searchable: true,
|
|
721
|
+
}),
|
|
722
|
+
},
|
|
723
|
+
}),
|
|
724
|
+
);
|
|
725
|
+
});
|
|
726
|
+
expect(() => validateBoot([feature])).toThrow(/sensitive.*searchable/);
|
|
709
727
|
});
|
|
710
728
|
});
|
|
711
729
|
|
|
@@ -758,7 +776,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
758
776
|
expect(() => validateBoot([feature])).toThrow(/piiEncrypted.*without a subject annotation/);
|
|
759
777
|
});
|
|
760
778
|
|
|
761
|
-
test("piiEncrypted combined with searchable
|
|
779
|
+
test("piiEncrypted combined with searchable passes (#1610)", () => {
|
|
762
780
|
const feature = defineFeature("test", (r) => {
|
|
763
781
|
r.entity(
|
|
764
782
|
"tenant",
|
|
@@ -774,7 +792,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
774
792
|
}),
|
|
775
793
|
);
|
|
776
794
|
});
|
|
777
|
-
expect(() => validateBoot([feature])).toThrow(
|
|
795
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
778
796
|
});
|
|
779
797
|
|
|
780
798
|
test("piiEncrypted combined with sortable throws", () => {
|
|
@@ -793,7 +811,7 @@ describe("validateBoot — piiEncrypted (kumiko-platform#231/#456)", () => {
|
|
|
793
811
|
}),
|
|
794
812
|
);
|
|
795
813
|
});
|
|
796
|
-
expect(() => validateBoot([feature])).toThrow(/
|
|
814
|
+
expect(() => validateBoot([feature])).toThrow(/sortable/);
|
|
797
815
|
});
|
|
798
816
|
|
|
799
817
|
test("piiEncrypted without access.read throws (kumiko-platform#460)", () => {
|
|
@@ -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
|
|
|
@@ -129,15 +129,25 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
132
|
+
// Sortierung liest die Projection-Spalte — die bleibt Ciphertext, also
|
|
133
|
+
// sortable + Subject-Annotation bleibt Boot-Fail. searchable ist seit
|
|
134
|
+
// #1610 erlaubt: der Search-Consumer decryptet in den abgeleiteten
|
|
135
|
+
// Index und forget purgt die Docs (siehe createSearchEventConsumer).
|
|
136
|
+
// sensitive + searchable bleibt verboten (nobody-may-read-back).
|
|
137
|
+
{
|
|
138
|
+
const flags = field as {
|
|
139
|
+
readonly searchable?: boolean;
|
|
140
|
+
readonly sortable?: boolean;
|
|
141
|
+
readonly sensitive?: boolean;
|
|
142
|
+
}; // @cast-boundary schema-walk
|
|
143
|
+
if ((annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) && flags.sortable === true) {
|
|
139
144
|
throw new Error(
|
|
140
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with {
|
|
145
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { sortable: true } — sorting reads the projection column, which is ciphertext at rest. For equality lookups use { lookupable: true }; drop sortable or keep the field plaintext (allowPlaintext).`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (flags.sensitive === true && flags.searchable === true) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means nobody may read the value back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not.`,
|
|
141
151
|
);
|
|
142
152
|
}
|
|
143
153
|
}
|
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(
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Per-feature changelog — each bundled feature has a `changes.json` that
|
|
2
|
+
// tracks breaking changes, improvements, and fixes per version. The CLI
|
|
3
|
+
// (`kumiko upgrade`) reads these to show apps what they need to migrate.
|
|
4
|
+
// All fields should be in English for consistency across the codebase.
|
|
5
|
+
//
|
|
6
|
+
// File I/O stays in the CLI (`bin/commands/upgrade.ts`) — this module is
|
|
7
|
+
// pure parse/validate so engine stays off the node:fs allowlist.
|
|
8
|
+
|
|
9
|
+
export type ChangelogType = "breaking" | "improvement" | "fix";
|
|
10
|
+
|
|
11
|
+
export type ChangelogEntry = {
|
|
12
|
+
readonly version: string;
|
|
13
|
+
readonly type: ChangelogType;
|
|
14
|
+
readonly title: string;
|
|
15
|
+
readonly detail?: string;
|
|
16
|
+
/** Required when type=breaking. Shown in `kumiko upgrade` output. */
|
|
17
|
+
readonly migration?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type FeatureChangelog = {
|
|
21
|
+
readonly feature: string;
|
|
22
|
+
readonly entries: readonly ChangelogEntry[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Parse a changes.json body. Callers own file I/O. */
|
|
26
|
+
export function parseFeatureChangelog(raw: string, featureName: string): FeatureChangelog | null {
|
|
27
|
+
try {
|
|
28
|
+
const entries = JSON.parse(raw) as unknown;
|
|
29
|
+
if (!Array.isArray(entries)) return null;
|
|
30
|
+
|
|
31
|
+
const validated: ChangelogEntry[] = [];
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (!isChangelogEntry(entry)) continue;
|
|
34
|
+
validated.push(entry);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { feature: featureName, entries: validated };
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isChangelogEntry(value: unknown): value is ChangelogEntry {
|
|
44
|
+
if (typeof value !== "object" || value === null) return false;
|
|
45
|
+
const obj = value as Record<string, unknown>;
|
|
46
|
+
if (typeof obj["version"] !== "string") return false;
|
|
47
|
+
if (!["breaking", "improvement", "fix"].includes(obj["type"] as string)) return false;
|
|
48
|
+
if (typeof obj["title"] !== "string") return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateChangelog(entry: ChangelogEntry): string[] {
|
|
53
|
+
const errors: string[] = [];
|
|
54
|
+
if (entry.type === "breaking" && !entry.migration) {
|
|
55
|
+
errors.push(`breaking change "${entry.title}" missing migration field`);
|
|
56
|
+
}
|
|
57
|
+
if (entry.type === "breaking" && entry.migration?.trim() === "") {
|
|
58
|
+
errors.push(`breaking change "${entry.title}" has empty migration field`);
|
|
59
|
+
}
|
|
60
|
+
return errors;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function compareVersions(a: string, b: string): number {
|
|
64
|
+
const pa = a.split(".").map(Number);
|
|
65
|
+
const pb = b.split(".").map(Number);
|
|
66
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
67
|
+
const na = pa[i] ?? 0;
|
|
68
|
+
const nb = pb[i] ?? 0;
|
|
69
|
+
if (na > nb) return 1;
|
|
70
|
+
if (na < nb) return -1;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function filterEntriesAfter(
|
|
76
|
+
entries: readonly ChangelogEntry[],
|
|
77
|
+
version: string,
|
|
78
|
+
): readonly ChangelogEntry[] {
|
|
79
|
+
return entries.filter((e) => compareVersions(e.version, version) > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function sortEntries(entries: readonly ChangelogEntry[]): readonly ChangelogEntry[] {
|
|
83
|
+
const order: Record<ChangelogType, number> = {
|
|
84
|
+
breaking: 0,
|
|
85
|
+
improvement: 1,
|
|
86
|
+
fix: 2,
|
|
87
|
+
};
|
|
88
|
+
return [...entries].sort((a, b) => {
|
|
89
|
+
const typeDiff = order[a.type] - order[b.type];
|
|
90
|
+
if (typeDiff !== 0) return typeDiff;
|
|
91
|
+
return compareVersions(b.version, a.version);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { compareByCodepoint } from "../utils";
|
|
10
10
|
import { isEncryptedAtRest } from "./config-helpers";
|
|
11
|
+
import type { ChangelogEntry } from "./feature-changelog";
|
|
11
12
|
import { qualifyEntityName } from "./qualified-name";
|
|
12
13
|
import type { Registry, UiHints } from "./types/feature";
|
|
13
14
|
|
|
@@ -64,6 +65,9 @@ export type ManifestFeature = {
|
|
|
64
65
|
readonly uiHints?: UiHints;
|
|
65
66
|
/** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
|
|
66
67
|
readonly tier?: string;
|
|
68
|
+
/** Per-feature changelog entries (from changes.json). Optional —
|
|
69
|
+
* absent when no changes.json exists or feature has no entries. */
|
|
70
|
+
readonly changelog?: readonly ChangelogEntry[];
|
|
67
71
|
};
|
|
68
72
|
|
|
69
73
|
export type FeatureManifest = {
|
|
@@ -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
|
);
|
package/src/engine/index.ts
CHANGED
|
@@ -163,6 +163,16 @@ export {
|
|
|
163
163
|
replacePattern,
|
|
164
164
|
VERSION_HEADER,
|
|
165
165
|
} from "./feature-ast";
|
|
166
|
+
export {
|
|
167
|
+
type ChangelogEntry,
|
|
168
|
+
type ChangelogType,
|
|
169
|
+
compareVersions,
|
|
170
|
+
type FeatureChangelog,
|
|
171
|
+
filterEntriesAfter,
|
|
172
|
+
parseFeatureChangelog,
|
|
173
|
+
sortEntries,
|
|
174
|
+
validateChangelog,
|
|
175
|
+
} from "./feature-changelog";
|
|
166
176
|
export {
|
|
167
177
|
type BuildManifestOptions,
|
|
168
178
|
buildManifestFromRegistry,
|
|
@@ -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 &&
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { schedulerIdForJobName } from "../job-runner";
|
|
3
|
+
|
|
4
|
+
describe("schedulerIdForJobName", () => {
|
|
5
|
+
test("strips dots and colons so BullMQ job ids stay under the 5-segment legacy heuristic", () => {
|
|
6
|
+
// Job id becomes repeat:<id>:<millis> — colons in <id> previously pushed
|
|
7
|
+
// the segment count to ≥5 and leaked a permanent hash per cron tick
|
|
8
|
+
// (fw#1603 / bullmq#3828).
|
|
9
|
+
const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
|
|
10
|
+
expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
|
|
11
|
+
expect(id.includes(":")).toBe(false);
|
|
12
|
+
expect(`repeat:${id}:1784992080000`.split(":").length).toBeLessThan(5);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("still collapses dotted QNs", () => {
|
|
16
|
+
expect(schedulerIdForJobName("app.job.tick")).toBe("scheduler-app-job-tick");
|
|
17
|
+
});
|
|
18
|
+
});
|
package/src/jobs/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
|
|
2
|
-
export { createJobRunner } from "./job-runner";
|
|
2
|
+
export { createJobRunner, schedulerIdForJobName } from "./job-runner";
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -34,6 +34,22 @@ function queueNameFor(prefix: string, lane: JobRunIn): string {
|
|
|
34
34
|
return `${prefix}-${lane}`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
|
|
39
|
+
* scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
|
|
40
|
+
* treated as old repeatables — spawning a new scheduler entry every tick and
|
|
41
|
+
* leaking permanent `repeat:*` hashes (taskforcesh/bullmq#3828, fw#1603 /
|
|
42
|
+
* publicstatus Redis OOM). Strip `.` and `:` from the job QN.
|
|
43
|
+
*/
|
|
44
|
+
export function schedulerIdForJobName(jobName: string): string {
|
|
45
|
+
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pre-sanitize id (`.` only) — remove on boot so colon-form ghosts die. */
|
|
49
|
+
function legacySchedulerIdForJobName(jobName: string): string {
|
|
50
|
+
return `scheduler-${jobName.replace(/\./g, "-")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export type JobLogEntry = {
|
|
38
54
|
level: "info" | "warn" | "error";
|
|
39
55
|
message: string;
|
|
@@ -473,12 +489,26 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
473
489
|
for (const [name, jobDef] of allJobs) {
|
|
474
490
|
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
475
491
|
if ("cron" in jobDef.trigger) {
|
|
492
|
+
const schedulerId = schedulerIdForJobName(name);
|
|
493
|
+
const legacyId = legacySchedulerIdForJobName(name);
|
|
494
|
+
// Drop pre-sanitize scheduler ids so colon-form ghosts stop firing.
|
|
495
|
+
if (legacyId !== schedulerId) {
|
|
496
|
+
try {
|
|
497
|
+
await consumerQueue.removeJobScheduler(legacyId);
|
|
498
|
+
} catch {
|
|
499
|
+
// skip: legacy scheduler absent (fresh install / already purged)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
476
502
|
await consumerQueue.upsertJobScheduler(
|
|
477
|
-
|
|
503
|
+
schedulerId,
|
|
478
504
|
{ pattern: jobDef.trigger.cron },
|
|
479
505
|
{
|
|
480
506
|
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
481
507
|
data: {},
|
|
508
|
+
opts: {
|
|
509
|
+
removeOnComplete: { count: 100 },
|
|
510
|
+
removeOnFail: { count: 50 },
|
|
511
|
+
},
|
|
482
512
|
},
|
|
483
513
|
);
|
|
484
514
|
}
|
|
@@ -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
|
|