@cosmicdrift/kumiko-framework 0.356.0 → 0.358.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  2. package/dist/crypto/pii-field-encryption.js +1 -0
  3. package/dist/crypto/subject-resolver.d.ts +2 -0
  4. package/dist/crypto/subject-resolver.js +9 -6
  5. package/dist/db/connection.d.ts +2 -0
  6. package/dist/db/connection.js +10 -0
  7. package/dist/db/event-store-executor-context.d.ts +4 -1
  8. package/dist/db/event-store-executor-context.js +4 -2
  9. package/dist/db/event-store-executor-write.js +6 -1
  10. package/dist/db/index.d.ts +1 -1
  11. package/dist/db/index.js +1 -1
  12. package/dist/db/queries/backfill-pii.js +58 -34
  13. package/dist/engine/boot-validator/card-meta-overflow.d.ts +10 -0
  14. package/dist/engine/boot-validator/card-meta-overflow.js +95 -0
  15. package/dist/engine/boot-validator/entity-handler.js +3 -2
  16. package/dist/engine/boot-validator/index.d.ts +1 -0
  17. package/dist/engine/boot-validator/index.js +4 -1
  18. package/dist/engine/boot-validator/pii-retention.js +7 -0
  19. package/dist/engine/boot-validator/query-refs.js +3 -0
  20. package/dist/engine/boot-validator/screens.js +7 -1
  21. package/dist/engine/boot-validator.d.ts +1 -1
  22. package/dist/engine/boot-validator.js +1 -1
  23. package/dist/engine/factories.js +23 -19
  24. package/dist/engine/feature-ast/extractors/resolve-import.d.ts +3 -1
  25. package/dist/engine/feature-ast/extractors/resolve-import.js +9 -8
  26. package/dist/engine/feature-ast/extractors/round3.js +3 -2
  27. package/dist/engine/feature-ast/extractors/shared.d.ts +12 -2
  28. package/dist/engine/feature-ast/extractors/shared.js +31 -11
  29. package/dist/engine/index.d.ts +1 -1
  30. package/dist/engine/index.js +1 -1
  31. package/dist/files/file-ref-entity.d.ts +6 -0
  32. package/dist/files/file-ref-table.d.ts +6 -0
  33. package/dist/i18n/required-surface-keys.js +1 -0
  34. package/dist/jobs/job-runner.d.ts +1 -0
  35. package/dist/jobs/job-runner.js +51 -13
  36. package/dist/pipeline/dispatch-shared.d.ts +1 -0
  37. package/dist/pipeline/dispatch-shared.js +1 -1
  38. package/dist/pipeline/dispatcher.d.ts +2 -0
  39. package/dist/pipeline/dispatcher.js +1 -0
  40. package/dist/pipeline/system-hooks.js +7 -3
  41. package/dist/stack/db.d.ts +3 -0
  42. package/dist/stack/db.js +7 -1
  43. package/dist/stack/test-stack.d.ts +4 -0
  44. package/dist/stack/test-stack.js +5 -0
  45. package/dist/testing/shared-entities.d.ts +10 -0
  46. package/dist/ui-types/card-column-roles.d.ts +14 -0
  47. package/dist/ui-types/card-column-roles.js +9 -0
  48. package/dist/ui-types/index.d.ts +2 -0
  49. package/dist/ui-types/index.js +1 -0
  50. package/package.json +4 -4
  51. package/src/changes.json +38 -0
@@ -5,7 +5,8 @@ const MODULE_RESOLUTION_OPTIONS = {
5
5
  target: ts.ScriptTarget.ESNext,
6
6
  };
7
7
  const MAX_RESOLUTION_DEPTH = 24;
8
- export function resolveModuleFile(fromFile, specifier) {
8
+ const DECLARATION_FILES_OK = { allowDeclarationFiles: true };
9
+ export function resolveModuleFile(fromFile, specifier, { allowDeclarationFiles = false } = {}) {
9
10
  const project = fromFile.getProject();
10
11
  // The project's own host, not ts.sys: it honours an in-memory file system
11
12
  // (Designer) and exists outside Node.
@@ -13,10 +14,10 @@ export function resolveModuleFile(fromFile, specifier) {
13
14
  const resolvedFileName = resolved.resolvedModule?.resolvedFileName;
14
15
  if (!resolvedFileName)
15
16
  return undefined;
16
- // A declaration file has no initializers to read. Installed packages that
17
- // ship .ts sources (the @cosmicdrift/* packages in a consumer repo) stay
18
- // resolvable, as they were via the type checker.
19
- if (resolvedFileName.endsWith(".d.ts"))
17
+ // A declaration file has no initializers to read. Callers that only need
18
+ // `export declare const X: "literal"` types opt in (installed packages
19
+ // resolve to dist/*.d.ts through their publishConfig exports).
20
+ if (!allowDeclarationFiles && resolvedFileName.endsWith(".d.ts"))
20
21
  return undefined;
21
22
  return fromFile.getProject().addSourceFileAtPathIfExists(resolvedFileName);
22
23
  }
@@ -71,7 +72,7 @@ function resolveViaNamedExportSpecifier(file, exportedName, visited, depth) {
71
72
  const moduleSpecifierValue = exportDecl.getModuleSpecifierValue();
72
73
  if (!moduleSpecifierValue)
73
74
  continue;
74
- const target = resolveModuleFile(file, moduleSpecifierValue);
75
+ const target = resolveModuleFile(file, moduleSpecifierValue, DECLARATION_FILES_OK);
75
76
  if (!target)
76
77
  continue;
77
78
  const resolved = resolveExportedVariable(target, originalName, visited, depth + 1);
@@ -87,7 +88,7 @@ function resolveLocalExportBinding(file, originalName, visited, depth) {
87
88
  const binding = findImportBindingForLocalName(file, originalName);
88
89
  if (!binding)
89
90
  return undefined;
90
- const target = resolveModuleFile(file, binding.moduleSpecifier);
91
+ const target = resolveModuleFile(file, binding.moduleSpecifier, DECLARATION_FILES_OK);
91
92
  if (!target)
92
93
  return undefined;
93
94
  return resolveExportedVariable(target, binding.importedName, visited, depth + 1);
@@ -103,7 +104,7 @@ function resolveViaStarExport(file, exportedName, visited, depth) {
103
104
  const moduleSpecifierValue = exportDecl.getModuleSpecifierValue();
104
105
  if (!moduleSpecifierValue)
105
106
  continue;
106
- const target = resolveModuleFile(file, moduleSpecifierValue);
107
+ const target = resolveModuleFile(file, moduleSpecifierValue, DECLARATION_FILES_OK);
107
108
  if (!target)
108
109
  continue;
109
110
  const resolved = resolveExportedVariable(target, exportedName, visited, depth + 1);
@@ -212,6 +212,7 @@ export function extractReferenceData(call, sourceFile) {
212
212
  ...(upsertKey !== undefined && { upsertKey }),
213
213
  });
214
214
  }
215
+ const USE_EXTENSION_READ_OPTIONS = { functionValuesAsRaw: true };
215
216
  export function extractUseExtension(call, sourceFile) {
216
217
  const args = call.getArguments();
217
218
  const first = args[0];
@@ -245,7 +246,7 @@ export function extractUseExtension(call, sourceFile) {
245
246
  ?.asKind(SyntaxKind.PropertyAssignment)
246
247
  ?.getInitializer();
247
248
  if (optionsInit) {
248
- const parsed = readDataLiteralNode(optionsInit);
249
+ const parsed = readDataLiteralNode(optionsInit, USE_EXTENSION_READ_OPTIONS);
249
250
  if (!isPlainObject(parsed)) {
250
251
  return fail("useExtension", sourceLocationFromNode(call, sourceFile), "options could not be read as a plain object");
251
252
  }
@@ -274,7 +275,7 @@ export function extractUseExtension(call, sourceFile) {
274
275
  const optionsArg = args[2];
275
276
  let options;
276
277
  if (optionsArg) {
277
- const parsed = readDataLiteralNode(optionsArg);
278
+ const parsed = readDataLiteralNode(optionsArg, USE_EXTENSION_READ_OPTIONS);
278
279
  if (!isPlainObject(parsed)) {
279
280
  return fail("useExtension", sourceLocationFromNode(call, sourceFile), "options could not be read as a plain object");
280
281
  }
@@ -1,4 +1,5 @@
1
- import type { CallExpression, Node, ObjectLiteralExpression } from "ts-morph";
1
+ import type { CallExpression, ObjectLiteralExpression } from "ts-morph";
2
+ import { Node } from "ts-morph";
2
3
  import type { ParseError } from "../parse.js";
3
4
  export type ExtractOutput<TPattern> = {
4
5
  readonly kind: "pattern";
@@ -26,7 +27,16 @@ export type RawRefSentinel = {
26
27
  };
27
28
  export declare function isRawRefSentinel(value: unknown): value is RawRefSentinel;
28
29
  export declare function containsRawRefSentinel(value: unknown): boolean;
29
- export declare function readDataLiteralNode(node: Node): unknown;
30
+ export type ReadDataLiteralOptions = {
31
+ /**
32
+ * Keep arrow/function-expression values as `__raw` sentinels instead of
33
+ * failing the whole read. Only for pattern options that are code by
34
+ * nature (useExtension); data-only definitions (entity, nav) must still
35
+ * reject functions.
36
+ */
37
+ readonly functionValuesAsRaw?: boolean;
38
+ };
39
+ export declare function readDataLiteralNode(node: Node, readOptions?: ReadDataLiteralOptions): unknown;
30
40
  /**
31
41
  * A node's literal string value, or the raw-ref sentinel when it resolves to
32
42
  * an unresolvable identifier/factory-call/member-access (see
@@ -1,4 +1,4 @@
1
- import { SyntaxKind } from "ts-morph";
1
+ import { Node, SyntaxKind } from "ts-morph";
2
2
  import { isPlainObject } from "../../../utils/is-plain-object.js";
3
3
  import { findImportBindingForLocalName, findScopedVariableDeclaration, resolveExportedVariable, resolveModuleFile, } from "./resolve-import.js";
4
4
  export function ok(pattern) {
@@ -55,7 +55,7 @@ export function containsRawRefSentinel(value) {
55
55
  return Object.values(value).some(containsRawRefSentinel);
56
56
  return false;
57
57
  }
58
- export function readDataLiteralNode(node) {
58
+ export function readDataLiteralNode(node, readOptions = {}) {
59
59
  const kind = node.getKind();
60
60
  switch (kind) {
61
61
  case SyntaxKind.StringLiteral:
@@ -74,7 +74,7 @@ export function readDataLiteralNode(node) {
74
74
  const expr = node.asKindOrThrow(SyntaxKind.PrefixUnaryExpression);
75
75
  if (expr.getOperatorToken() !== SyntaxKind.MinusToken)
76
76
  return undefined;
77
- const inner = readDataLiteralNode(expr.getOperand());
77
+ const inner = readDataLiteralNode(expr.getOperand(), readOptions);
78
78
  if (typeof inner !== "number")
79
79
  return undefined;
80
80
  return -inner;
@@ -83,7 +83,7 @@ export function readDataLiteralNode(node) {
83
83
  const arr = node.asKindOrThrow(SyntaxKind.ArrayLiteralExpression);
84
84
  const out = [];
85
85
  for (const el of arr.getElements()) {
86
- const value = readDataLiteralNode(el);
86
+ const value = readDataLiteralNode(el, readOptions);
87
87
  if (value === undefined)
88
88
  return undefined;
89
89
  out.push(value);
@@ -100,7 +100,7 @@ export function readDataLiteralNode(node) {
100
100
  const initializer = propAssign.getInitializer();
101
101
  if (!initializer)
102
102
  return undefined;
103
- const value = readDataLiteralNode(initializer);
103
+ const value = readDataLiteralNode(initializer, readOptions);
104
104
  if (value === undefined)
105
105
  return undefined;
106
106
  out[readPropertyKey(propAssign)] = value;
@@ -108,11 +108,11 @@ export function readDataLiteralNode(node) {
108
108
  return out;
109
109
  }
110
110
  case SyntaxKind.AsExpression:
111
- return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.AsExpression).getExpression());
111
+ return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.AsExpression).getExpression(), readOptions);
112
112
  case SyntaxKind.SatisfiesExpression:
113
- return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.SatisfiesExpression).getExpression());
113
+ return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.SatisfiesExpression).getExpression(), readOptions);
114
114
  case SyntaxKind.ParenthesizedExpression:
115
- return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression());
115
+ return readDataLiteralNode(node.asKindOrThrow(SyntaxKind.ParenthesizedExpression).getExpression(), readOptions);
116
116
  case SyntaxKind.Identifier:
117
117
  case SyntaxKind.CallExpression:
118
118
  case SyntaxKind.PropertyAccessExpression:
@@ -121,6 +121,11 @@ export function readDataLiteralNode(node) {
121
121
  // resolving/inlining it would silently rewrite the source on the next
122
122
  // parse->render roundtrip (see readDataLiteralNode doc above).
123
123
  return { __raw: node.getText() };
124
+ case SyntaxKind.ArrowFunction:
125
+ case SyntaxKind.FunctionExpression:
126
+ return readOptions.functionValuesAsRaw
127
+ ? { __raw: node.getText() }
128
+ : undefined;
124
129
  default:
125
130
  return undefined;
126
131
  }
@@ -178,6 +183,15 @@ function unwrapLiteralInitializer(node) {
178
183
  return unwrapLiteralInitializer(paren.getExpression());
179
184
  return undefined;
180
185
  }
186
+ // `export declare const NAME: "value"` in a .d.ts has no initializer, only a
187
+ // string-literal type.
188
+ function readDeclaredStringLiteralType(decl) {
189
+ const typeNode = decl.getTypeNode();
190
+ if (!typeNode || !Node.isLiteralTypeNode(typeNode))
191
+ return undefined;
192
+ const literal = typeNode.getLiteral();
193
+ return Node.isStringLiteral(literal) ? literal.getLiteralValue() : undefined;
194
+ }
181
195
  /**
182
196
  * Resolves a bare Identifier to the string value of its declaration's
183
197
  * initializer (`export const EXT_TENANT_DATA = "tenant-data" as const`),
@@ -200,12 +214,18 @@ function resolveIdentifierToStringLiteral(identifier) {
200
214
  const binding = findImportBindingForLocalName(id.getSourceFile(), name);
201
215
  if (!binding)
202
216
  return undefined;
203
- const targetFile = resolveModuleFile(id.getSourceFile(), binding.moduleSpecifier);
217
+ const targetFile = resolveModuleFile(id.getSourceFile(), binding.moduleSpecifier, {
218
+ allowDeclarationFiles: true,
219
+ });
204
220
  if (!targetFile)
205
221
  return undefined;
206
222
  const decl = resolveExportedVariable(targetFile, binding.importedName);
207
- const init = decl?.getInitializer();
208
- return init ? unwrapLiteralInitializer(init) : undefined;
223
+ if (!decl)
224
+ return undefined;
225
+ const init = decl.getInitializer();
226
+ if (init)
227
+ return unwrapLiteralInitializer(init);
228
+ return readDeclaredStringLiteralType(decl);
209
229
  }
210
230
  /**
211
231
  * Like `readNameLiteral`, but for callers that must preserve an
@@ -3,7 +3,7 @@ export { hasAccess } from "./access.js";
3
3
  export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, type PrincipalProfile, type PrincipalStatus, type PrincipalStatusPlugin, TENANT_TEARDOWN_STATUSES, type TenantLifecycleStatusPlugin, } from "./active-membership.js";
4
4
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
5
5
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
6
- export { collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
6
+ export { type CardMetaOverflow, collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
7
7
  export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema.js";
8
8
  export type { ConfigFeatureSchema } from "./build-config-feature-schema.js";
9
9
  export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
@@ -4,7 +4,7 @@ export { hasAccess } from "./access.js";
4
4
  export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, TENANT_TEARDOWN_STATUSES, } from "./active-membership.js";
5
5
  export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
6
6
  export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
7
- export { collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
7
+ export { collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
8
8
  export { buildAppSchema } from "./build-app-schema.js";
9
9
  export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
10
10
  export { buildTarget } from "./build-target.js";
@@ -4,6 +4,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -82,6 +85,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
82
85
  readonly description?: string;
83
86
  readonly maxLength?: number;
84
87
  readonly minLength?: number;
88
+ readonly autoComplete?: string;
85
89
  readonly required?: boolean;
86
90
  readonly searchable?: boolean;
87
91
  readonly sortable?: boolean;
@@ -104,6 +108,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
104
108
  readonly description?: string;
105
109
  readonly maxLength?: number;
106
110
  readonly minLength?: number;
111
+ readonly autoComplete?: string;
107
112
  readonly required?: boolean;
108
113
  readonly searchable?: boolean;
109
114
  readonly sortable?: boolean;
@@ -126,6 +131,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
126
131
  readonly description?: string;
127
132
  readonly maxLength?: number;
128
133
  readonly minLength?: number;
134
+ readonly autoComplete?: string;
129
135
  readonly required?: boolean;
130
136
  readonly searchable?: boolean;
131
137
  readonly sortable?: boolean;
@@ -4,6 +4,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
6
  readonly minLength?: number;
7
+ readonly autoComplete?: string;
7
8
  readonly required?: boolean;
8
9
  readonly searchable?: boolean;
9
10
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
26
27
  readonly description?: string;
27
28
  readonly maxLength?: number;
28
29
  readonly minLength?: number;
30
+ readonly autoComplete?: string;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -48,6 +50,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
48
50
  readonly description?: string;
49
51
  readonly maxLength?: number;
50
52
  readonly minLength?: number;
53
+ readonly autoComplete?: string;
51
54
  readonly required?: boolean;
52
55
  readonly searchable?: boolean;
53
56
  readonly sortable?: boolean;
@@ -82,6 +85,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
82
85
  readonly description?: string;
83
86
  readonly maxLength?: number;
84
87
  readonly minLength?: number;
88
+ readonly autoComplete?: string;
85
89
  readonly required?: boolean;
86
90
  readonly searchable?: boolean;
87
91
  readonly sortable?: boolean;
@@ -104,6 +108,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
104
108
  readonly description?: string;
105
109
  readonly maxLength?: number;
106
110
  readonly minLength?: number;
111
+ readonly autoComplete?: string;
107
112
  readonly required?: boolean;
108
113
  readonly searchable?: boolean;
109
114
  readonly sortable?: boolean;
@@ -126,6 +131,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
126
131
  readonly description?: string;
127
132
  readonly maxLength?: number;
128
133
  readonly minLength?: number;
134
+ readonly autoComplete?: string;
129
135
  readonly required?: boolean;
130
136
  readonly searchable?: boolean;
131
137
  readonly sortable?: boolean;
@@ -283,6 +283,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
283
283
  pushKey(out, mint.reveal.title);
284
284
  pushKey(out, mint.reveal.warning);
285
285
  pushKey(out, mint.reveal.confirmLabel);
286
+ pushKey(out, mint.reveal.acknowledge);
286
287
  for (const revealField of mint.reveal.fields) {
287
288
  pushKey(out, revealField.label);
288
289
  }
@@ -9,6 +9,7 @@ import { type AppContext, type DispatchWriteRef, type JobRunIn, type JobTriggerE
9
9
  */
10
10
  export declare function schedulerIdForJobName(jobName: string): string;
11
11
  export declare function bootJobIdForJobName(jobName: string): string;
12
+ export declare const ORPHAN_SCHEDULER_GRACE_MS: number;
12
13
  export type JobLogEntry = {
13
14
  level: "info" | "warn" | "error";
14
15
  message: string;
@@ -73,9 +73,48 @@ export function schedulerIdForJobName(jobName) {
73
73
  export function bootJobIdForJobName(jobName) {
74
74
  return `boot-${jobName.replace(/[.:]/g, "-")}`;
75
75
  }
76
- // ponytail: migration shim, remove after fw#1603 deploy is everywhere.
77
- function legacySchedulerIdForJobName(jobName) {
78
- return `scheduler-${jobName.replace(/\./g, "-")}`;
76
+ const SCHEDULER_PAGE_SIZE = 100;
77
+ // Rolling deploys run old and new pods against the same Redis for a while; an
78
+ // old runner restarting in that window must not delete the scheduler of a
79
+ // cron job the new version just added.
80
+ export const ORPHAN_SCHEDULER_GRACE_MS = 60 * 60 * 1000;
81
+ function schedulerSeenKey(queue) {
82
+ return queue.toKey("kumiko-scheduler-seen");
83
+ }
84
+ // A cron job dropped from the app (or an older scheduler-id format) leaves its
85
+ // scheduler in Redis, where it keeps firing jobs the worker no longer knows
86
+ // ("Unknown job"). Best-effort: a Redis hiccup here must not stop the start.
87
+ async function removeOrphanJobSchedulers(queue, expectedSchedulerIds, lane, log) {
88
+ try {
89
+ // Collect first: removing while paging would shift the indices.
90
+ const existingKeys = [];
91
+ for (let start = 0;; start += SCHEDULER_PAGE_SIZE) {
92
+ const page = await queue.getJobSchedulers(start, start + SCHEDULER_PAGE_SIZE - 1);
93
+ for (const scheduler of page)
94
+ existingKeys.push(scheduler.key);
95
+ if (page.length < SCHEDULER_PAGE_SIZE)
96
+ break;
97
+ }
98
+ const client = await queue.client;
99
+ const seenAt = await client.hgetall(schedulerSeenKey(queue));
100
+ const now = Date.now();
101
+ for (const key of existingKeys) {
102
+ if (expectedSchedulerIds.has(key))
103
+ continue;
104
+ const lastSeen = Number(seenAt[key]);
105
+ if (key in seenAt && now - lastSeen < ORPHAN_SCHEDULER_GRACE_MS)
106
+ continue;
107
+ await queue.removeJobScheduler(key);
108
+ await client.hdel(schedulerSeenKey(queue), key);
109
+ log.info("removed orphan job scheduler", { schedulerId: key, lane });
110
+ }
111
+ }
112
+ catch (err) {
113
+ log.warn("orphan job scheduler cleanup failed", {
114
+ lane,
115
+ error: err instanceof Error ? err.message : String(err),
116
+ });
117
+ }
79
118
  }
80
119
  // Deterministic per-tenant child job id, derived from the perTenant
81
120
  // wrapper's own BullMQ job id. A cron tick's wrapper id is
@@ -1108,21 +1147,13 @@ export function createJobRunner(options) {
1108
1147
  // Only schedule cron + boot for jobs that belong to this lane. Jobs
1109
1148
  // assigned to the other lane get their cron/boot wiring from the
1110
1149
  // runner running on that lane. Running both here would double-fire.
1150
+ const expectedSchedulerIds = new Set();
1111
1151
  for (const [name, jobDef] of allJobs) {
1112
1152
  if (laneForJob(jobDef) !== consumerLane)
1113
1153
  continue;
1114
1154
  if ("cron" in jobDef.trigger) {
1115
1155
  const schedulerId = schedulerIdForJobName(name);
1116
- const legacyId = legacySchedulerIdForJobName(name);
1117
- // Drop pre-sanitize scheduler ids so colon-form ghosts stop firing.
1118
- if (legacyId !== schedulerId) {
1119
- try {
1120
- await consumerQueue.removeJobScheduler(legacyId);
1121
- }
1122
- catch {
1123
- // skip: legacy scheduler absent (fresh install / already purged)
1124
- }
1125
- }
1156
+ expectedSchedulerIds.add(schedulerId);
1126
1157
  await consumerQueue.upsertJobScheduler(schedulerId, { pattern: jobDef.trigger.cron }, {
1127
1158
  name: jobDef.perTenant ? `_perTenant:${name}` : name,
1128
1159
  data: {},
@@ -1133,8 +1164,15 @@ export function createJobRunner(options) {
1133
1164
  ...buildRetryBullOpts(jobDef),
1134
1165
  },
1135
1166
  });
1167
+ await (await consumerQueue.client).hset(schedulerSeenKey(consumerQueue), {
1168
+ [schedulerId]: String(Date.now()),
1169
+ });
1136
1170
  }
1137
1171
  }
1172
+ await removeOrphanJobSchedulers(consumerQueue, expectedSchedulerIds, consumerLane, {
1173
+ info: (msg, data) => context.log ? context.log.info(msg, data) : errorLogger.debug(msg, data),
1174
+ warn: errorLogger.warn,
1175
+ });
1138
1176
  // Persistent marker outside the swept completed/failed sets: a plain
1139
1177
  // Redis hash at one key per consumer queue, fields = boot job ids
1140
1178
  // already enqueued (BullMQ's IRedisClient has no set commands, only
@@ -47,6 +47,7 @@ export type DispatchContext = {
47
47
  meter: ReturnType<typeof getFallbackMeter>;
48
48
  membershipQuery: string;
49
49
  memberReadTimeoutMs: number;
50
+ outsideTransactionDb: DbConnection | undefined;
50
51
  };
51
52
  export declare const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10000;
52
53
  export declare function resolveDbSource(ctx: DispatchContext, tx: DbTx | undefined): DbConnection | DbTx | undefined;
@@ -248,7 +248,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
248
248
  // that is meant to outlive the request. Computed before `systemDb` below
249
249
  // so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
250
250
  // this same TenantDb instead of a second, independently-built one.
251
- const outsideTxSource = resolveDbSource(ctx, undefined);
251
+ const outsideTxSource = ctx.outsideTransactionDb ?? resolveDbSource(ctx, undefined);
252
252
  const rawDbOutsideTransaction = outsideTxSource
253
253
  ? buildTenantScopedDb(outsideTxSource, undefined)
254
254
  : undefined;
@@ -1,5 +1,6 @@
1
1
  import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
2
2
  import type { SseBroker } from "../api/sse-broker.js";
3
+ import type { DbConnection } from "../db/connection.js";
3
4
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension.js";
4
5
  import type { TenantId } from "../engine/types/identifiers.js";
5
6
  import type { ActiveMembershipResult, AppContext, DispatchWriteRef, JobRunnerRef, MemberReader, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
@@ -16,6 +17,7 @@ export type DispatcherOptions = {
16
17
  jobRunner?: JobRunnerRef;
17
18
  membershipQuery?: string;
18
19
  memberReadTimeoutMs?: number;
20
+ outsideTransactionDb?: DbConnection;
19
21
  effectiveFeatures?: EffectiveFeaturesResolver;
20
22
  sseBroker?: SseBroker;
21
23
  cacheSync?: CacheSyncBus | null;
@@ -97,6 +97,7 @@ export function createDispatcher(registry, context, options = {}) {
97
97
  meter: dispatcherMeter,
98
98
  membershipQuery,
99
99
  memberReadTimeoutMs,
100
+ outsideTransactionDb: options.outsideTransactionDb,
100
101
  };
101
102
  const dispatcher = {
102
103
  async write(typeOrRef, payload, user, requestId) {
@@ -37,15 +37,19 @@ import { InternalError } from "../errors/index.js";
37
37
  // call per tenant instead of one round-trip per event; handler stays the
38
38
  // per-event fallback the dispatcher falls back to when a batch throws.
39
39
  export const SEARCH_CONSUMER_NAME = "system:consumer:search";
40
+ // An executor update with includeDeleted leaves the row deleted; indexing it would make it findable again.
41
+ function isUpdateOfSoftDeletedRow(event, verb) {
42
+ return (verb === "updated" && reconstructStateForSearch(event.payload, "updated")["isDeleted"] === true);
43
+ }
40
44
  // Resolves what one event implies for the search index, without touching
41
45
  // the adapter — same decision tree the old per-event handler ran inline.
42
46
  async function resolveSearchOperation(event, ctx, registry) {
43
47
  const entityName = event.aggregateType;
44
48
  const verb = event.type.split(".").pop();
45
49
  const tenantId = event.tenantId;
46
- // skip: delete/forgotten remove the index entry — reconstruct only
47
- // makes sense for created/updated/restored (field data in payload).
48
- if (verb === "deleted" || verb === "forgotten") {
50
+ // skip: delete/forgotten (and an update on a still soft-deleted row) remove the
51
+ // index entry — reconstruct only makes sense for live created/updated/restored.
52
+ if (verb === "deleted" || verb === "forgotten" || isUpdateOfSoftDeletedRow(event, verb)) {
49
53
  return { kind: "remove", tenantId, entityType: entityName, entityId: event.aggregateId };
50
54
  }
51
55
  let state;
@@ -2,6 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
2
2
  declare function requireEnv(name: string): string;
3
3
  export type TestDb = {
4
4
  db: DbConnection;
5
+ outsideTransactionDb: DbConnection;
5
6
  client: unknown;
6
7
  dbName: string;
7
8
  cleanup: () => Promise<void>;
@@ -10,6 +11,8 @@ export type CreateTestDbOptions = {
10
11
  readonly baseUrl?: string;
11
12
  readonly dbName?: string;
12
13
  readonly persistent?: boolean;
14
+ readonly poolMax?: number;
15
+ readonly outsideTransactionPoolMax?: number;
13
16
  };
14
17
  /**
15
18
  * Provider-agnostische Test-DB. createConnection liest DB_PROVIDER.
package/dist/stack/db.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Provider-agnostic via createConnection (DB_PROVIDER env).
3
3
  // postgres-js = default. DB_PROVIDER=bun = Bun.SQL (experimentell).
4
4
  import { createConnection } from "../db/api.js";
5
+ import { DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX } from "../db/connection.js";
5
6
  import { createDatabase, databaseExists, dropDatabaseIfExists } from "../db/queries/test-stack.js";
6
7
  import { ensureTemporalPolyfill } from "../time/polyfill.js";
7
8
  import { generateId } from "../utils/index.js";
@@ -37,15 +38,20 @@ export async function createTestDb(arg) {
37
38
  await admin.close();
38
39
  }
39
40
  const testUrl = url.replace(/\/[^/]+$/, `/${dbName}`);
40
- const conn = await createConnection(testUrl);
41
+ const conn = await createConnection(testUrl, opts.poolMax !== undefined ? { maxConnections: opts.poolMax } : {});
42
+ const outsideConn = await createConnection(testUrl, {
43
+ maxConnections: opts.outsideTransactionPoolMax ?? DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX,
44
+ });
41
45
  const { createEventsTable } = await import("../event-store/index.js");
42
46
  await createEventsTable(conn.db);
43
47
  return {
44
48
  db: conn.db,
49
+ outsideTransactionDb: outsideConn.db,
45
50
  client: conn.client,
46
51
  dbName,
47
52
  cleanup: async () => {
48
53
  await conn.close();
54
+ await outsideConn.close();
49
55
  if (!opts.persistent) {
50
56
  const admin2 = await createConnection(adminUrl, { maxConnections: 1 });
51
57
  try {
@@ -101,6 +101,10 @@ export type TestStackOptions = {
101
101
  * owns its lifecycle. Default false (test contract). Used by
102
102
  * dev-server wiring to survive hot-reloads. */
103
103
  persistentDb?: boolean;
104
+ /** Cap on the main app pool (default: driver default). Lets tests saturate it deliberately. */
105
+ dbPoolMax?: number;
106
+ /** Size of the dedicated pool behind ctx.dbOutsideTransaction / ctx.outsideTransaction. */
107
+ outsideTransactionPoolMax?: number;
104
108
  /** Forwarded to buildServer — when set, requests without a JWT pass
105
109
  * through as anonymous instead of 401. See AnonymousAccessConfig.
106
110
  * Akzeptiert entweder einen statischen Config-Object ODER eine Factory
@@ -44,6 +44,10 @@ export async function setupTestStack(options) {
44
44
  createTestDb({
45
45
  ...(options.dbName !== undefined && { dbName: options.dbName }),
46
46
  ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
47
+ ...(options.dbPoolMax !== undefined && { poolMax: options.dbPoolMax }),
48
+ ...(options.outsideTransactionPoolMax !== undefined && {
49
+ outsideTransactionPoolMax: options.outsideTransactionPoolMax,
50
+ }),
47
51
  }),
48
52
  createTestRedis(options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix }),
49
53
  ]);
@@ -286,6 +290,7 @@ export async function setupTestStack(options) {
286
290
  dispatcherOptions: {
287
291
  idempotency,
288
292
  cacheSync,
293
+ outsideTransactionDb: testDb.outsideTransactionDb,
289
294
  ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
290
295
  ...(jobRunner && { jobRunner }),
291
296
  },