@cosmicdrift/kumiko-framework 0.336.0 → 0.337.1

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 (104) hide show
  1. package/dist/api/auth-middleware.js +43 -19
  2. package/dist/api/auth-routes.d.ts +1 -0
  3. package/dist/api/auth-routes.js +20 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +4 -5
  6. package/dist/api/routes.js +1 -1
  7. package/dist/api/server.js +3 -3
  8. package/dist/api/sse-route.js +14 -1
  9. package/dist/bun-db/query.js +6 -4
  10. package/dist/crypto/event-pii.js +3 -3
  11. package/dist/crypto/kek-source.js +1 -0
  12. package/dist/db/blind-index-cleanup.js +1 -1
  13. package/dist/db/event-store-executor-read.js +15 -4
  14. package/dist/db/queries/event-consumer.d.ts +2 -2
  15. package/dist/db/queries/event-consumer.js +5 -4
  16. package/dist/db/queries/event-store.js +6 -6
  17. package/dist/db/queries/projection-rebuild.js +4 -4
  18. package/dist/db/queries/seed-context.js +6 -6
  19. package/dist/derivatives/derivatives-context.js +5 -2
  20. package/dist/engine/boot-validator/access-declarations.js +2 -1
  21. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  22. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  23. package/dist/engine/boot-validator/screens.js +12 -0
  24. package/dist/engine/build-app-schema.js +2 -1
  25. package/dist/engine/build-config-feature-schema.js +4 -0
  26. package/dist/engine/define-feature.js +1 -0
  27. package/dist/engine/define-handler.js +3 -5
  28. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  29. package/dist/engine/feature-ast/extractors/index.js +1 -1
  30. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  31. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  32. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  33. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  34. package/dist/engine/feature-ast/index.d.ts +1 -1
  35. package/dist/engine/feature-ast/parse.js +3 -1
  36. package/dist/engine/feature-ast/patch.d.ts +3 -0
  37. package/dist/engine/feature-ast/patch.js +8 -11
  38. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  39. package/dist/engine/feature-ast/patcher.js +3 -0
  40. package/dist/engine/feature-ast/pattern-change-schema.d.ts +590 -1
  41. package/dist/engine/feature-ast/pattern-change-schema.js +17 -2
  42. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  43. package/dist/engine/feature-ast/patterns.js +1 -0
  44. package/dist/engine/feature-ast/render.js +9 -0
  45. package/dist/engine/feature-builder-state.d.ts +2 -1
  46. package/dist/engine/feature-builder-state.js +1 -0
  47. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  48. package/dist/engine/feature-config-events-jobs.js +20 -0
  49. package/dist/engine/feature-ui-extensions.js +15 -21
  50. package/dist/engine/index.d.ts +1 -1
  51. package/dist/engine/pattern-library/library.js +2 -1
  52. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  53. package/dist/engine/pattern-library/static-schemas.js +47 -4
  54. package/dist/engine/registry-facade.js +7 -0
  55. package/dist/engine/registry-ingest.js +7 -0
  56. package/dist/engine/registry-state.d.ts +2 -1
  57. package/dist/engine/registry-state.js +1 -0
  58. package/dist/engine/registry-validate.d.ts +1 -1
  59. package/dist/engine/registry-validate.js +39 -17
  60. package/dist/engine/registry.js +2 -2
  61. package/dist/engine/types/index.d.ts +2 -2
  62. package/dist/engine/types/index.js +1 -1
  63. package/dist/engine/types/screen.d.ts +1 -1
  64. package/dist/engine/types/screen.js +1 -1
  65. package/dist/errors/classes.d.ts +5 -0
  66. package/dist/errors/classes.js +15 -0
  67. package/dist/errors/index.d.ts +1 -1
  68. package/dist/errors/index.js +1 -1
  69. package/dist/i18n/required-surface-keys.js +3 -5
  70. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  71. package/dist/migrations/pending-rebuilds.js +7 -3
  72. package/dist/pipeline/dispatch-batch.js +2 -2
  73. package/dist/pipeline/dispatch-query.js +3 -5
  74. package/dist/pipeline/dispatch-stream.js +2 -4
  75. package/dist/pipeline/dispatch-write.js +2 -4
  76. package/dist/pipeline/dispatcher.js +1 -1
  77. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  78. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  79. package/dist/pipeline/event-dispatcher.js +16 -25
  80. package/dist/pipeline/handler-access-error.d.ts +3 -0
  81. package/dist/pipeline/handler-access-error.js +17 -0
  82. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  83. package/dist/pipeline/member-read-only-transaction.js +8 -3
  84. package/dist/pipeline/system-hooks.js +7 -1
  85. package/dist/pipeline/system-identity-switch.js +1 -1
  86. package/dist/search/meilisearch-adapter.js +0 -2
  87. package/dist/stack/push-entity-projection-tables.js +5 -3
  88. package/dist/stack/redis.d.ts +1 -0
  89. package/dist/stack/redis.js +11 -2
  90. package/dist/stack/table-helpers.d.ts +3 -1
  91. package/dist/stack/table-helpers.js +9 -7
  92. package/dist/stack/test-stack.js +1 -7
  93. package/dist/testing/handler-context.js +3 -6
  94. package/dist/ui-types/index.d.ts +1 -1
  95. package/dist/ui-types/index.js +1 -1
  96. package/dist/upgrade-cli.js +29 -2
  97. package/package.json +4 -4
  98. package/src/changes.json +98 -0
  99. package/src/scripts/codemod/collect-source-files.ts +48 -0
  100. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  101. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  102. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  103. package/src/scripts/codemod/pii-personal-migration.ts +11 -21
  104. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -42,6 +42,21 @@ export class AccessDeniedError extends KumikoError {
42
42
  });
43
43
  }
44
44
  }
45
+ // A caller without a signed-in principal (the synthesised anonymousAccess
46
+ // user) hit a handler that needs one. 401, not 403: an expired auth cookie is
47
+ // dropped by the browser, so this is how a lapsed session reaches the server.
48
+ export class UnauthenticatedError extends KumikoError {
49
+ code = "unauthenticated";
50
+ httpStatus = 401;
51
+ constructor(opts) {
52
+ super({
53
+ message: opts?.message ?? "this handler requires a signed-in user",
54
+ i18nKey: "auth.errors.missingToken",
55
+ ...(opts?.details !== undefined && { details: opts.details }),
56
+ ...(opts?.cause && { cause: opts.cause }),
57
+ });
58
+ }
59
+ }
45
60
  export class NotFoundError extends KumikoError {
46
61
  code = "not_found";
47
62
  httpStatus = 404;
@@ -1,5 +1,5 @@
1
1
  export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
2
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
3
3
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
4
4
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
5
5
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
@@ -1,4 +1,4 @@
1
- export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
1
+ export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
2
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
3
3
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
4
4
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
@@ -144,6 +144,9 @@ function pushRelatedListSectionKeys(out, section) {
144
144
  }
145
145
  for (const action of section.rowActions ?? [])
146
146
  pushRowActionKeys(out, action);
147
+ pushKey(out, section.groupBy?.label);
148
+ for (const label of Object.values(section.groupBy?.labels ?? {}))
149
+ pushKey(out, label);
147
150
  }
148
151
  function pushExpandableRowKeys(out, section) {
149
152
  pushRelatedListSectionKeys(out, section);
@@ -330,11 +333,6 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
330
333
  }
331
334
  if (section.kind === "relatedList") {
332
335
  pushRelatedListSectionKeys(out, section);
333
- if (section.groupBy !== undefined) {
334
- pushKey(out, section.groupBy.label);
335
- for (const label of Object.values(section.groupBy.labels ?? {}))
336
- pushKey(out, label);
337
- }
338
336
  continue;
339
337
  }
340
338
  if (isWriteFormEditSection(section)) {
@@ -1,6 +1,7 @@
1
1
  import type { DbConnection } from "../db/connection.js";
2
2
  import type { Registry } from "../engine/types/index.js";
3
3
  import type { JobRunner } from "../jobs/index.js";
4
+ import type { Logger } from "../logging/types.js";
4
5
  import { type RebuildResult } from "../pipeline/index.js";
5
6
  export declare const pendingRebuildsTable: import("@cosmicdrift/kumiko-types/schema-table-types").SchemaTable;
6
7
  export declare function createPendingRebuildsTable(db: DbConnection): Promise<void>;
@@ -56,6 +57,7 @@ export declare function rebuildProjectionOrMultiStream(name: string, deps: {
56
57
  readonly errorPolicy?: {
57
58
  readonly skipApplyErrors?: boolean;
58
59
  };
60
+ readonly logger?: Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>;
59
61
  }): Promise<RebuildResult>;
60
62
  /** Logs a finished queue drain and throws when any projection failed; the
61
63
  * failed tables stay queued. Shared by every `schema apply` entry point so the
@@ -65,12 +65,16 @@ export async function clearPendingRebuilds(db, rows) {
65
65
  }
66
66
  }
67
67
  // buildProjectionTableIndex maps both single-stream and multi-stream projection
68
- // tables to their name, so the rebuild has to pick the matching path. Queue
69
- // drains are automatic rebuilds: a failed MSP replay rolls back, stays queued
70
- // and must not park the still-intact live consumer as dead.
68
+ // tables to their name, so the rebuild has to pick the matching path. Callers
69
+ // (queue drains, inline enqueue, the manual rebuild job) all rethrow the error,
70
+ // so it is recorded there; a failed MSP replay rolls back and must not park
71
+ // the still-intact live consumer as dead.
71
72
  export async function rebuildProjectionOrMultiStream(name, deps) {
72
73
  const { db, registry, signal, errorPolicy } = deps;
73
74
  if (registry.getAllMultiStreamProjections().has(name)) {
75
+ if (errorPolicy?.skipApplyErrors === true) {
76
+ createFallbackLogger("migrations:pending-rebuilds", deps.logger).warn(`skipApplyErrors ignored for multi-stream projection "${name}"; declare msp.errorMode.rebuild instead`, { projection: name });
77
+ }
74
78
  return rebuildMultiStreamProjection(name, {
75
79
  db,
76
80
  registry,
@@ -24,8 +24,8 @@ export async function runBatch(ctx, commands, user, requestId, inheritedOrigin)
24
24
  if (!current?.signal) {
25
25
  return runBatchBody(ctx, commands, user, requestId, inheritedOrigin);
26
26
  }
27
- // Strip the signal: a disconnect would roll back the tx, idempotency would
28
- // cache a 500 for the uncommitted write and afterCommit effects would be lost.
27
+ // Strip the signal: a client disconnect must not roll back a write whose
28
+ // afterCommit effects would then be lost.
29
29
  const { signal: _signal, ...withoutSignal } = current;
30
30
  return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId, inheritedOrigin));
31
31
  }
@@ -1,8 +1,9 @@
1
1
  import { hasAccess } from "../engine/access.js";
2
2
  import { filterReadFields } from "../engine/field-access.js";
3
- import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors/index.js";
3
+ import { NotFoundError, validationErrorFromZod } from "../errors/index.js";
4
4
  import { assertNoSecretLeak } from "../secrets/index.js";
5
5
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runHandlerInstrumented, } from "./dispatch-shared.js";
6
+ import { handlerAccessError } from "./handler-access-error.js";
6
7
  import { runInMemberReadOnlyTransaction } from "./member-read-only-transaction.js";
7
8
  // Standalone query execution — used by the public dispatcher.query() and
8
9
  // by ctx.query/ctx.queryAs inside handlers. Runs the handler, applies
@@ -34,10 +35,7 @@ async function executeQueryInner(ctx, type, payload, user, origin, tx) {
34
35
  // so in normal boots this branch shouldn't fire — the guard is belt-and-
35
36
  // suspenders in case a handler sneaks through (e.g. runtime injection).
36
37
  if (!hasAccess(user, handler.access)) {
37
- throw new AccessDeniedError({
38
- message: `access denied for ${type}`,
39
- details: { handler: type },
40
- });
38
+ throw handlerAccessError(user, type);
41
39
  }
42
40
  const parsed = handler.schema.safeParse(payload);
43
41
  if (!parsed.success) {
@@ -3,6 +3,7 @@ import { hasAccess } from "../engine/access.js";
3
3
  import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
4
4
  import { assertNoSecretLeak } from "../secrets/index.js";
5
5
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
6
+ import { handlerAccessError } from "./handler-access-error.js";
6
7
  // Standalone stream execution — used by the public dispatcher.stream().
7
8
  // Chunk-by-chunk analog of executeQuery: same gate order (feature → rate-
8
9
  // limit → access → validation → handler), but yields incrementally instead
@@ -25,10 +26,7 @@ async function* executeStreamInner(ctx, type, payload, user, origin) {
25
26
  await ensureFeatureEnabled(ctx, type, user.tenantId);
26
27
  await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
27
28
  if (!hasAccess(user, handler.access)) {
28
- throw new AccessDeniedError({
29
- message: `access denied for ${type}`,
30
- details: { handler: type },
31
- });
29
+ throw handlerAccessError(user, type);
32
30
  }
33
31
  const parsed = handler.schema.safeParse(payload);
34
32
  if (!parsed.success) {
@@ -13,6 +13,7 @@ import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, memb
13
13
  import { assertNoSecretLeak } from "../secrets/index.js";
14
14
  import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, checkFeatureEnabled, enforceRateLimit, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
15
15
  import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
16
+ import { handlerAccessError } from "./handler-access-error.js";
16
17
  import { runProjections } from "./projections-runner.js";
17
18
  function getTable(ctx, entityName) {
18
19
  const { registry, tableCache } = ctx;
@@ -322,10 +323,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
322
323
  // so in normal boots this branch shouldn't fire — the guard is belt-and-
323
324
  // suspenders in case a handler sneaks through (e.g. runtime injection).
324
325
  if (!hasAccess(user, handler.access)) {
325
- return writeFailure(new AccessDeniedError({
326
- message: `access denied for ${type}`,
327
- details: { handler: type },
328
- }));
326
+ return writeFailure(handlerAccessError(user, type));
329
327
  }
330
328
  const parsed = handler.schema.safeParse(payload);
331
329
  if (!parsed.success) {
@@ -67,7 +67,7 @@ export function createDispatcher(registry, context, options = {}) {
67
67
  if (isDeprecatedCrossTenantHandler(def.handler)) {
68
68
  context.log?.warn(DEPRECATED_CROSS_TENANT_SIGNAL, {
69
69
  handler: def.name,
70
- migration: 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun scripts/codemod/migrate-cross-tenant.ts)',
70
+ migration: 'replace crossTenant: true with escapeHatch: { reason: "<why this operator handler reads/writes every tenant>" } (bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/migrate-cross-tenant.ts)',
71
71
  });
72
72
  }
73
73
  }
@@ -67,5 +67,4 @@ export type PersistedConsumerOutcome = DeliveryOutcome & {
67
67
  readonly pendingGaps: readonly PendingGapEntry[];
68
68
  };
69
69
  export declare function persistConsumerOutcome(tx: DbTx, name: string, instanceId: string, outcome: PersistedConsumerOutcome): Promise<void>;
70
- export declare function persistConsumerPassFailure(tx: DbTx, name: string, instanceId: string, errorMessage: string): Promise<void>;
71
70
  export declare function emitLagFromTx(tx: DbTx, consumerName: string, instanceId: string, cursor: bigint, meter: Meter): Promise<void>;
@@ -4,7 +4,7 @@
4
4
  // resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
5
5
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
6
6
  import { requestContext } from "../api/request-context.js";
7
- import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, recordConsumerPassFailure, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
7
+ import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
8
8
  import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries/event-store.js";
9
9
  import { coerceRow, extractTableInfo } from "../db/query.js";
10
10
  import { qnScope } from "../engine/qualified-name.js";
@@ -319,13 +319,6 @@ function reportSkippedEvent(consumer, eventId, e, errMessage, context) {
319
319
  export async function persistConsumerOutcome(tx, name, instanceId, outcome) {
320
320
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
321
321
  }
322
- // Best-effort record of a pass that threw before persistConsumerOutcome
323
- // could run (event-dispatcher.ts's processConsumer catch). The caller opens
324
- // its OWN transaction for this — the one that rolled back never committed
325
- // anything, including this write, if it happened inside the same tx.
326
- export async function persistConsumerPassFailure(tx, name, instanceId, errorMessage) {
327
- await recordConsumerPassFailure(tx, name, instanceId, errorMessage);
328
- }
329
322
  // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
330
323
  // consistent with the cursor we just advanced to. `MAX(id)` on the events
331
324
  // table is an O(1) reverse-index scan — cheap even under load.
@@ -1,9 +1,9 @@
1
- import { selectSnapshotXmax, selectSnapshotXmin } from "../db/queries/event-consumer.js";
1
+ import { recordConsumerPassFailure, selectSnapshotXmax, selectSnapshotXmin, } from "../db/queries/event-consumer.js";
2
2
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
3
3
  import { EVENTS_PUBSUB_CHANNEL } from "../event-store/index.js";
4
4
  import { emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, getFallbackMeter, getFallbackTracer, } from "../observability/index.js";
5
5
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
6
- import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, persistConsumerPassFailure, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
6
+ import { acquireConsumerState, consumerInstanceId, deliverEvents, emitLagFromTx, fetchPendingEvents, markProcessing, persistConsumerOutcome, preRegisterConsumers, selectIdleConsumerKeys, } from "./event-dispatcher-delivery.js";
7
7
  import { partitionBurntGaps, splitRangeExcludingIds, toIdRanges } from "./pending-gap-ranges.js";
8
8
  const DEFAULT_BATCH_SIZE = 200;
9
9
  const DEFAULT_POLL_MS = 100;
@@ -35,6 +35,15 @@ export function createEventDispatcher(options) {
35
35
  }
36
36
  const tracer = options.tracer ?? getFallbackTracer();
37
37
  const meter = options.meter ?? getFallbackMeter();
38
+ // A standalone dispatcher has no logger on its context; console.error keeps the failure in pod logs.
39
+ const logDispatcherError = (message) => {
40
+ if (context.log) {
41
+ context.log.error(message);
42
+ }
43
+ else {
44
+ console.error(message);
45
+ }
46
+ };
38
47
  // Tracks which (consumer, instanceId) pairs already fired
39
48
  // kumiko_event_consumer_rearm_exhausted_total, so a consumer stuck dead
40
49
  // across many poll passes emits the ops-signal once, not every pass
@@ -220,7 +229,7 @@ export function createEventDispatcher(options) {
220
229
  //
221
230
  // A failure here (e.g. a transient DB blip) must not abort the whole
222
231
  // pass — timer/LISTEN callers swallow doPass()'s rejection silently
223
- // (see start()), which would mean no log, no persistConsumerPassFailure,
232
+ // (see start()), which would mean no log, no recordConsumerPassFailure,
224
233
  // and no backoff for every consumer this tick. Falling back to "nothing
225
234
  // proven idle" instead routes every consumer through its normal
226
235
  // acquireConsumerState path, whose own try/catch already covers this.
@@ -254,12 +263,7 @@ export function createEventDispatcher(options) {
254
263
  emitEventDispatcherListenConnected(meter, false);
255
264
  const msg = e instanceof Error ? e.message : String(e);
256
265
  const logMsg = `[event-dispatcher] idle pre-check failed, falling back to per-consumer locking every tick until it recovers: ${msg}`;
257
- if (context.log) {
258
- context.log.error(logMsg);
259
- }
260
- else {
261
- console.error(logMsg);
262
- }
266
+ logDispatcherError(logMsg);
263
267
  }
264
268
  }
265
269
  // Every consumer runs its own turn concurrently (own TX, own cursor,
@@ -396,15 +400,7 @@ export function createEventDispatcher(options) {
396
400
  // callers and at-least-once-with-duplicate-delivery on the next pass;
397
401
  // neither is what we want, so ops needs to see it.
398
402
  const msg = e instanceof Error ? e.message : String(e);
399
- if (context.log) {
400
- context.log.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
401
- }
402
- else {
403
- // No logger wired onto this context (e.g. createEventDispatcher used
404
- // standalone) — console.error is the only way ops still sees this in
405
- // pod logs instead of the failure vanishing with the rolled-back tx.
406
- console.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
407
- }
403
+ logDispatcherError(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
408
404
  span.setStatus("error", msg);
409
405
  // The whole pass rolled back, so markProcessing/persistConsumerOutcome
410
406
  // never committed — attempts/last_error are stuck at their pre-pass
@@ -413,18 +409,13 @@ export function createEventDispatcher(options) {
413
409
  // so any failure here is swallowed after logging.
414
410
  try {
415
411
  await db.begin(async (tx) => {
416
- await persistConsumerPassFailure(tx, consumer.name, instanceId, msg);
412
+ await recordConsumerPassFailure(tx, consumer.name, instanceId, msg);
417
413
  });
418
414
  }
419
415
  catch (persistErr) {
420
416
  const persistMsg = persistErr instanceof Error ? persistErr.message : String(persistErr);
421
417
  const failureMsg = `[event-dispatcher] ${consumer.name} failed to persist pass-failure state: ${persistMsg}`;
422
- if (context.log) {
423
- context.log.error(failureMsg);
424
- }
425
- else {
426
- console.error(failureMsg);
427
- }
418
+ logDispatcherError(failureMsg);
428
419
  }
429
420
  // Escalate the backoff so this consumer isn't retried on the very
430
421
  // next tick — see PASS_FAILURE_BASE_BACKOFF_MS/MAX_MS above.
@@ -0,0 +1,3 @@
1
+ import type { SessionUser } from "../engine/types/index.js";
2
+ import { type KumikoError } from "../errors/index.js";
3
+ export declare function handlerAccessError(user: Pick<SessionUser, "roles">, type: string): KumikoError;
@@ -0,0 +1,17 @@
1
+ import { isAnonymousSessionUser } from "../engine/system-user.js";
2
+ import { AccessDeniedError, UnauthenticatedError } from "../errors/index.js";
3
+ // Anonymous callers get 401 so a client whose session lapsed (browser dropped
4
+ // the expired auth cookie) can tell "sign in again" apart from "signed in but
5
+ // not allowed", which stays 403.
6
+ export function handlerAccessError(user, type) {
7
+ if (isAnonymousSessionUser(user)) {
8
+ return new UnauthenticatedError({
9
+ message: `${type} requires a signed-in user`,
10
+ details: { handler: type },
11
+ });
12
+ }
13
+ return new AccessDeniedError({
14
+ message: `access denied for ${type}`,
15
+ details: { handler: type },
16
+ });
17
+ }
@@ -28,11 +28,17 @@ function assertEntryHandlerHighRisk(args) {
28
28
  },
29
29
  });
30
30
  }
31
+ // Stream handlers have no agent hint, so the "declare risk high" advice cannot apply to them.
32
+ function highRiskRemedy(entryHandler) {
33
+ return entryHandler.qn.includes(":stream:")
34
+ ? 'stream handlers cannot declare an agent risk and always resolve "mid" — dispatch a risk "high" write handler directly instead'
35
+ : `declare agent: { risk: "high" } on "${entryHandler.qn}"`;
36
+ }
31
37
  export function assertIrreversibleOperationAllowed(operation) {
32
38
  assertEntryHandlerHighRisk({
33
39
  reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
34
40
  buildMessage: (entryHandler) => `Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
35
- `"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
41
+ `"${entryHandler.risk}" — ${highRiskRemedy(entryHandler)}. The ` +
36
42
  "directly called handler must carry it; delegating via ctx.write/writeAs does not.",
37
43
  details: { operation },
38
44
  });
@@ -49,8 +55,8 @@ export function assertInstructionFieldWriteAllowed(entityName, verb, writtenFiel
49
55
  assertEntryHandlerHighRisk({
50
56
  reason: FrameworkReasons.instructionFieldWriteRequiresHighRisk,
51
57
  buildMessage: (entryHandler) => `Handler "${entryHandler.qn}" ${verb}s "${entityName}" field(s) ${fields.join(", ")} ` +
52
- `flagged readAsInstruction: true but resolves agent risk "${entryHandler.risk}" — declare ` +
53
- `agent: { risk: "high" } on "${entryHandler.qn}". The directly called handler must carry ` +
58
+ `flagged readAsInstruction: true but resolves agent risk "${entryHandler.risk}" — ` +
59
+ `${highRiskRemedy(entryHandler)}. The directly called handler must carry ` +
54
60
  "it; delegating via ctx.write/writeAs does not.",
55
61
  details: { entityName, verb, fields },
56
62
  });
@@ -6,8 +6,13 @@ const PG_READ_ONLY_SQLSTATE = "25006";
6
6
  function isReadOnlyTransactionViolation(e) {
7
7
  return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
8
8
  }
9
+ class SavepointDiscard extends Error {
10
+ constructor() {
11
+ super("savepoint discarded on purpose");
12
+ this.name = "SavepointDiscard";
13
+ }
14
+ }
9
15
  async function runInDiscardedReadOnlySavepoint(tx, fn) {
10
- const discardMarker = {};
11
16
  const settled = {};
12
17
  try {
13
18
  await runInSavepoint(tx, async (sp) => {
@@ -16,11 +21,11 @@ async function runInDiscardedReadOnlySavepoint(tx, fn) {
16
21
  // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
17
22
  settled.outcome = { value: await fn(sp) };
18
23
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
19
- throw discardMarker;
24
+ throw new SavepointDiscard();
20
25
  });
21
26
  }
22
27
  catch (e) {
23
- if (e !== discardMarker)
28
+ if (!(e instanceof SavepointDiscard))
24
29
  throw e;
25
30
  }
26
31
  if (!settled.outcome) {
@@ -204,7 +204,13 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
204
204
  const row = await createTenantDb(baseDb, tenantId).fetchOne(table, where);
205
205
  if (!row)
206
206
  return undefined;
207
- return rehydrateCompoundTypes(row, entity);
207
+ const state = rehydrateCompoundTypes(row, entity);
208
+ // The event payload never carries sensitive fields; extensions must not see them via the row either.
209
+ for (const [name, field] of Object.entries(entity.fields)) {
210
+ if ("sensitive" in field && field.sensitive === true)
211
+ delete state[name];
212
+ }
213
+ return state;
208
214
  }
209
215
  // #1610 — subject-annotated searchable fields are ciphertext in the event
210
216
  // payload; decrypt into the derived index only. No KMS → omit ciphertext
@@ -13,7 +13,7 @@ function hasSameClaims(caller, asUser) {
13
13
  if (keys.length !== Object.keys(asUserClaims).length)
14
14
  return false;
15
15
  return keys.every((key) => Object.hasOwn(asUserClaims, key) &&
16
- JSON.stringify(callerClaims[key]) === JSON.stringify(asUserClaims[key]));
16
+ Bun.deepEquals(callerClaims[key], asUserClaims[key], true));
17
17
  }
18
18
  // claims drive ownership row filters and origin makes a ctx read-only, so both must match exactly.
19
19
  export function isSelfDelegation(caller, asUser) {
@@ -153,7 +153,6 @@ export function createMeilisearchAdapter(options) {
153
153
  // skip: empty batch — avoid an unnecessary Meilisearch round-trip
154
154
  if (items.length === 0)
155
155
  return;
156
- await ensureConfigured(tenantId);
157
156
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
158
157
  const ids = items.map((i) => meilisearchDocId(i.entityType, i.entityId));
159
158
  await awaitSucceededTask(index.deleteDocuments(ids), REMOVE_TOLERATED_ERROR_CODES);
@@ -178,7 +177,6 @@ export function createMeilisearchAdapter(options) {
178
177
  }));
179
178
  },
180
179
  async remove(tenantId, entityType, entityId) {
181
- await ensureConfigured(tenantId);
182
180
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
183
181
  await awaitSucceededTask(index.deleteDocument(meilisearchDocId(entityType, entityId)), REMOVE_TOLERATED_ERROR_CODES);
184
182
  },
@@ -1,6 +1,6 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { columnNamesOf, tableExists } from "../db/schema-inspection.js";
3
- import { addMissingColumns, tableToMeta, unsafePushTables } from "./table-helpers.js";
3
+ import { addMissingColumns, addMissingIndexes, tableToMeta, unsafePushTables, } from "./table-helpers.js";
4
4
  // biome-ignore lint/suspicious/noConsole: stack-internal status logging
5
5
  const logInfo = (msg) => console.log(msg);
6
6
  /**
@@ -44,7 +44,7 @@ export async function pushEntityProjectionTables(stack, registry) {
44
44
  function isUnsafeToAutoAdd(col) {
45
45
  return col.notNull && !col.primaryKey && col.defaultSql === undefined;
46
46
  }
47
- // Backfills columns a persistent dev DB predates; a required column with no
47
+ // Backfills columns and indexes a persistent dev DB predates; a required column with no
48
48
  // default can't be added safely on a table that may hold rows, so that
49
49
  // stays a boot error instead.
50
50
  async function syncExistingProjectionTable(stack, physical, table) {
@@ -53,7 +53,8 @@ async function syncExistingProjectionTable(stack, physical, table) {
53
53
  const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
54
54
  if (missingColumns.length === 0) {
55
55
  logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
56
- // skip: live table already has every declared column
56
+ await addMissingIndexes(stack.db, physical, meta.indexes);
57
+ // skip: table is already in sync, only indexes needed backfilling
57
58
  return;
58
59
  }
59
60
  const unsafeColumns = missingColumns.filter(isUnsafeToAutoAdd);
@@ -70,4 +71,5 @@ async function syncExistingProjectionTable(stack, physical, table) {
70
71
  logInfo(`[kumiko-stack] table ${physical} is missing column(s) ` +
71
72
  `${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`);
72
73
  await addMissingColumns(stack.db, physical, missingColumns);
74
+ await addMissingIndexes(stack.db, physical, meta.indexes);
73
75
  }
@@ -8,4 +8,5 @@ export type TestRedis = {
8
8
  flushNamespace: () => Promise<void>;
9
9
  cleanup: () => Promise<void>;
10
10
  };
11
+ export declare function queueNamePrefixForTestRedis(keyPrefix: string): string;
11
12
  export declare function createTestRedis(): Promise<TestRedis>;
@@ -1,5 +1,11 @@
1
1
  import { generateId } from "../utils/index.js";
2
2
  import { requireEnv } from "./db.js";
3
+ // BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
4
+ // parallel stacks sharing the prod default queue name would consume each
5
+ // other's jobs. BullMQ rejects `:` in queue names.
6
+ export function queueNamePrefixForTestRedis(keyPrefix) {
7
+ return keyPrefix.split(":").filter(Boolean).join("-");
8
+ }
3
9
  export async function createTestRedis() {
4
10
  const Redis = (await import("ioredis")).Redis;
5
11
  const redisUrl = requireEnv("REDIS_URL");
@@ -8,13 +14,13 @@ export async function createTestRedis() {
8
14
  // prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
9
15
  const prefix = `kt:${generateId().slice(-8)}:`;
10
16
  const redis = new Redis(redisUrl, { keyPrefix: prefix });
11
- async function flushNamespace() {
17
+ async function deleteKeysMatching(pattern) {
12
18
  // Open a prefix-less client for the scan — ioredis' keyPrefix is applied
13
19
  // per-command but SCAN's returned keys are full names, so managing the
14
20
  // del set with the prefix already on the connection is error-prone.
15
21
  const raw = new Redis(redisUrl);
16
22
  try {
17
- const stream = raw.scanStream({ match: `${prefix}*`, count: 500 });
23
+ const stream = raw.scanStream({ match: pattern, count: 500 });
18
24
  const keys = [];
19
25
  for await (const batch of stream)
20
26
  keys.push(...batch);
@@ -25,6 +31,7 @@ export async function createTestRedis() {
25
31
  raw.disconnect();
26
32
  }
27
33
  }
34
+ const flushNamespace = () => deleteKeysMatching(`${prefix}*`);
28
35
  return {
29
36
  redis,
30
37
  redisUrl,
@@ -32,6 +39,8 @@ export async function createTestRedis() {
32
39
  flushNamespace,
33
40
  cleanup: async () => {
34
41
  await flushNamespace();
42
+ // The derived per-stack JobRunner queues sit outside the keyPrefix.
43
+ await deleteKeysMatching(`bull:${queueNamePrefixForTestRedis(prefix)}-*`);
35
44
  redis.disconnect();
36
45
  },
37
46
  };
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "../db/connection.js";
2
- import type { ColumnMeta, EntityTableMeta } from "../db/entity-table-meta.js";
2
+ import type { ColumnMeta, EntityTableMeta, IndexMeta } from "../db/entity-table-meta.js";
3
3
  import type { EventDispatcher } from "../pipeline/index.js";
4
4
  /**
5
5
  * Bypass: creates an entity-table directly without going through the
@@ -11,6 +11,8 @@ export declare function unsafeEnsureEntityTable(db: DbConnection, entity: import
11
11
  export declare function tableToMeta(table: unknown): EntityTableMeta;
12
12
  /** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
13
13
  export declare function addMissingColumns(db: DbConnection, tableName: string, columns: readonly ColumnMeta[]): Promise<void>;
14
+ /** CREATE [UNIQUE] INDEX IF NOT EXISTS for exactly the given indexes. */
15
+ export declare function addMissingIndexes(db: DbConnection, tableName: string, indexes: readonly IndexMeta[]): Promise<void>;
14
16
  /**
15
17
  * Bypass: pushes table definitions to the database directly. Produces
16
18
  * CREATE TABLE IF NOT EXISTS + CREATE INDEX statements via renderTableDdl
@@ -66,6 +66,14 @@ export async function addMissingColumns(db, tableName, columns) {
66
66
  await alterTableAddColumn(db, tableName, col.name, type, defaultClause, notNull);
67
67
  }
68
68
  }
69
+ /** CREATE [UNIQUE] INDEX IF NOT EXISTS for exactly the given indexes. */
70
+ export async function addMissingIndexes(db, tableName, indexes) {
71
+ for (const idx of indexes) {
72
+ const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
73
+ const colList = idx.columns.map((c) => `"${c}"`).join(", ");
74
+ await createIndexIfNotExists(db, kind, idx.name, tableName, colList, idx.whereSql);
75
+ }
76
+ }
69
77
  /**
70
78
  * Bypass: pushes table definitions to the database directly. Produces
71
79
  * CREATE TABLE IF NOT EXISTS + CREATE INDEX statements via renderTableDdl
@@ -90,13 +98,7 @@ export async function unsafePushTables(db, tables, prevTables) {
90
98
  const newCols = meta.columns.filter((c) => !prevCols.has(c.name));
91
99
  await addMissingColumns(db, meta.tableName, newCols);
92
100
  const prevIdxNames = new Set(prev.indexes.map((i) => i.name));
93
- for (const idx of meta.indexes) {
94
- if (!prevIdxNames.has(idx.name)) {
95
- const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
96
- const colList = idx.columns.map((c) => `"${c}"`).join(", ");
97
- await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList, idx.whereSql);
98
- }
99
- }
101
+ await addMissingIndexes(db, meta.tableName, meta.indexes.filter((i) => !prevIdxNames.has(i.name)));
100
102
  }
101
103
  else {
102
104
  const statements = renderTableDdl(meta);
@@ -11,16 +11,10 @@ import { createInMemorySearchAdapter } from "../search/index.js";
11
11
  import { createTestDb } from "./db.js";
12
12
  import { createJobFailureTracker, drainJobs } from "./drain-jobs.js";
13
13
  import { createEventCollector } from "./event-collector.js";
14
- import { createTestRedis } from "./redis.js";
14
+ import { createTestRedis, queueNamePrefixForTestRedis } from "./redis.js";
15
15
  import { createRequestHelper } from "./request-helper.js";
16
16
  import { unsafePushTables } from "./table-helpers.js";
17
17
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
18
- // BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
19
- // parallel stacks sharing the prod default queue name would consume each
20
- // other's jobs. BullMQ rejects `:` in queue names.
21
- function queueNamePrefixForTestRedis(keyPrefix) {
22
- return keyPrefix.split(":").filter(Boolean).join("-");
23
- }
24
18
  export async function setupTestStack(options) {
25
19
  const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
26
20
  const enabledHooks = options.systemHooks ?? ["sse", "search"];
@@ -5,7 +5,7 @@
5
5
  // production services (delivery-service uses it to run cross-feature notify
6
6
  // calls without a real dispatcher). Hence the runtime classification despite
7
7
  // living under `testing/` — no vitest imports, no test side-effects.
8
- import { ANONYMOUS_ROLE } from "../engine/system-user.js";
8
+ import { createAnonymousUser } from "../engine/system-user.js";
9
9
  import { DEFAULT_LOCALE } from "../i18n/request-locale.js";
10
10
  import { createNoopMetricsHandle, getFallbackTracer } from "../observability/index.js";
11
11
  import { createTzContext } from "../time/index.js";
@@ -31,11 +31,8 @@ export function bridgeStub(opts) {
31
31
  // anonymous default values when no user is passed explicitly. Test code
32
32
  // that cares about identity passes its own SessionUser here and gets it
33
33
  // back on ctx.
34
- const stubUser = opts?.user ?? {
35
- id: "00000000-0000-0000-0000-000000000000",
36
- tenantId: "00000000-0000-0000-0000-000000000000", // @cast-boundary engine-bridge
37
- roles: [ANONYMOUS_ROLE],
38
- };
34
+ const stubUser = opts?.user ??
35
+ createAnonymousUser("00000000-0000-0000-0000-000000000000"); // @cast-boundary engine-bridge
39
36
  return {
40
37
  user: stubUser,
41
38
  query: notAvailable("query"), // @cast-boundary engine-bridge
@@ -10,7 +10,7 @@ export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPe
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
12
  export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
- export { metricField } from "../engine/types/screen.js";
13
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
16
16
  export type { WorkspaceDefinition } from "../engine/types/workspace.js";