@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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +7 -3
  4. package/src/api/__tests__/sse-broker.test.ts +27 -18
  5. package/src/api/routes.ts +3 -3
  6. package/src/api/sse-broker.ts +12 -13
  7. package/src/bun-db/query.ts +2 -2
  8. package/src/crypto/index.ts +1 -0
  9. package/src/crypto/subject-resolver.ts +15 -0
  10. package/src/db/__tests__/decimal-field.test.ts +3 -3
  11. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  12. package/src/db/__tests__/migrate-runner.test.ts +19 -1
  13. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +4 -4
  15. package/src/db/collect-table-metas.ts +3 -3
  16. package/src/db/entity-table-meta.ts +49 -32
  17. package/src/db/index.ts +5 -1
  18. package/src/db/migrate-runner.ts +18 -11
  19. package/src/db/table-builder.ts +2 -2
  20. package/src/db/tenant-db.ts +1 -1
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +24 -6
  22. package/src/engine/__tests__/store-table.test.ts +8 -11
  23. package/src/engine/boot-validator/pii-retention.ts +18 -8
  24. package/src/engine/constants.ts +0 -4
  25. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  26. package/src/engine/feature-changelog.ts +93 -0
  27. package/src/engine/feature-manifest.ts +4 -0
  28. package/src/engine/feature-ui-extensions.ts +1 -1
  29. package/src/engine/index.ts +10 -0
  30. package/src/engine/registry-state.ts +2 -2
  31. package/src/engine/validate-projection-allowlist.ts +1 -1
  32. package/src/jobs/__tests__/scheduler-id.test.ts +18 -0
  33. package/src/jobs/index.ts +1 -1
  34. package/src/jobs/job-runner.ts +31 -1
  35. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  36. package/src/migrations/projection-table-index.ts +1 -1
  37. package/src/pipeline/__tests__/dispatcher.test.ts +61 -0
  38. package/src/pipeline/dispatch-stream.ts +22 -12
  39. package/src/pipeline/system-hooks.ts +54 -7
  40. package/src/schema-cli.ts +2 -3
  41. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +164 -0
  42. package/src/search/index.ts +1 -0
  43. package/src/search/purge-subject.ts +135 -0
  44. package/src/testing/__tests__/wait-for.test.ts +8 -4
  45. 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 { buildEntityTableMeta, defineUnmanagedTable } from "./entity-table-meta";
62
+ export {
63
+ buildEntityTableMeta,
64
+ defineUnmanagedTable,
65
+ deriveEntityTableMeta,
66
+ } from "./entity-table-meta";
63
67
  export type {
64
68
  EntityLifecycleVerb,
65
69
  EventStoreExecutor,
@@ -74,22 +74,14 @@ CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
74
74
  )
75
75
  `.trim();
76
76
 
77
- // Splits SQL-file text into individual statements on top-level `;`. A plain
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
  );
@@ -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
- // buildEntityTableMeta).
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 buildEntityTableMeta).
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])) {
@@ -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 buildEntityTableMeta/defineUnmanagedTable result), not a direct
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 throws", () => {
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(/searchable.*cannot work/);
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.*cannot work/);
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 throws", () => {
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(/piiEncrypted.*searchable.*cannot work/);
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(/piiEncrypted.*sortable.*cannot work/);
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 = buildEntityTableMeta("rt-probe-managed", managedEntity);
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
- defineFeature("probe", (r) => {
85
- r.storeTable(readPrefixed, { reason: "test" });
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 = buildEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
195
+ const piiMeta = deriveEntityTableMeta("rt-pii-probe", piiEntity, { source: "unmanaged" });
199
196
 
200
- test("buildEntityTableMeta records the subject-annotated field names", () => {
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
- // Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
133
- // unmöglich searchable würde Plaintext-Kopien in den Suchindex
134
- // schieben, sortable sortiert Base64-Blobs. Equality lookupable.
135
- if (annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) {
136
- const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
137
- if (flags.searchable === true || flags.sortable === true) {
138
- const offending = flags.searchable === true ? "searchable" : "sortable";
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 { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
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
  }
@@ -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
- // buildEntityTableMeta) or a captured identifier — never an inline literal,
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 buildEntityTableMeta(..., { source: "unmanaged" })) — ` +
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
  );
@@ -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, buildEntityTableMeta } from "../db/entity-table-meta";
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, buildEntityTableMeta(entityName, entity), tableMeta);
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 (buildEntityTableMeta / defineUnmanagedTable — no handle-spread).
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";
@@ -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
- `scheduler-${name.replace(/\./g, "-")}`,
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 { buildEntityTableMeta } from "../../db/entity-table-meta";
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 = buildEntityTableMeta("kdriftGen", entity);
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/buildEntityTableMeta an die Table-Definition hängt.
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