@cosmicdrift/kumiko-framework 0.353.0 → 0.354.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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,8 +1,9 @@
1
1
  import { extractPgError } from "../db/pg-error.js";
2
2
  import { asRawClient, runInSavepoint, transaction } from "../db/query.js";
3
- import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
3
+ import { AccessDeniedError, InternalError, MemberReadTimeoutError, memberResolutionReadOnlyDenied, } from "../errors/index.js";
4
4
  import { resolveDbSource } from "./dispatch-shared.js";
5
5
  const PG_READ_ONLY_SQLSTATE = "25006";
6
+ const PG_QUERY_CANCELED_SQLSTATE = "57014";
6
7
  function isReadOnlyTransactionViolation(e) {
7
8
  return extractPgError(e)?.code === PG_READ_ONLY_SQLSTATE;
8
9
  }
@@ -12,12 +13,27 @@ class SavepointDiscard extends Error {
12
13
  this.name = "SavepointDiscard";
13
14
  }
14
15
  }
15
- async function runInDiscardedReadOnlySavepoint(tx, fn) {
16
+ function isStatementTimeout(e) {
17
+ return extractPgError(e)?.code === PG_QUERY_CANCELED_SQLSTATE;
18
+ }
19
+ // SET cannot take bind parameters, so the value is interpolated — only after it is proven a plain integer.
20
+ function memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout) {
21
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
22
+ throw new InternalError({
23
+ message: `member read timeout must be a positive integer, got ${timeoutMs}`,
24
+ });
25
+ }
26
+ const idleTimeout = bindIdleTimeout
27
+ ? `; SET LOCAL idle_in_transaction_session_timeout = ${timeoutMs}`
28
+ : "";
29
+ return `SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = ${timeoutMs}${idleTimeout}`;
30
+ }
31
+ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, fn) {
16
32
  const settled = {};
17
33
  try {
18
34
  await runInSavepoint(tx, async (sp) => {
19
35
  // kumiko-lint-ignore raw-sql transaction characteristic, no query helper
20
- await asRawClient(sp).unsafe("SET TRANSACTION READ ONLY");
36
+ await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
21
37
  // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
22
38
  settled.outcome = { value: await fn(sp) };
23
39
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
@@ -36,9 +52,10 @@ async function runInDiscardedReadOnlySavepoint(tx, fn) {
36
52
  return settled.outcome.value;
37
53
  }
38
54
  export async function runInMemberReadOnlyTransaction(ctx, tx, fn) {
55
+ const timeoutMs = ctx.memberReadTimeoutMs;
39
56
  try {
40
57
  if (tx)
41
- return await runInDiscardedReadOnlySavepoint(tx, fn);
58
+ return await runInDiscardedReadOnlySavepoint(tx, timeoutMs, false, fn);
42
59
  const pool = resolveDbSource(ctx, undefined);
43
60
  if (!pool) {
44
61
  throw new InternalError({
@@ -48,13 +65,16 @@ export async function runInMemberReadOnlyTransaction(ctx, tx, fn) {
48
65
  // Savepoint even on the pool path: Postgres rejects a READ WRITE reset (SQLSTATE 25001) inside a subtransaction.
49
66
  return await transaction(pool, (outer) =>
50
67
  // @cast-boundary driver begin() handle — structurally a DbTx, same shape runInSavepoint's caller relies on
51
- runInDiscardedReadOnlySavepoint(outer, fn));
68
+ runInDiscardedReadOnlySavepoint(outer, timeoutMs, true, fn));
52
69
  }
53
70
  catch (e) {
54
71
  if (e instanceof AccessDeniedError)
55
72
  throw e;
56
73
  if (isReadOnlyTransactionViolation(e))
57
74
  throw memberResolutionReadOnlyDenied(e);
75
+ if (isStatementTimeout(e)) {
76
+ throw new MemberReadTimeoutError({ timeoutMs, ...(e instanceof Error && { cause: e }) });
77
+ }
58
78
  throw e;
59
79
  }
60
80
  }
@@ -8,7 +8,7 @@ import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../even
8
8
  import { upcastStoredEvents } from "../event-store/upcaster.js";
9
9
  import { emitProjectionRebuild } from "../observability/standard-metrics.js";
10
10
  import { SHARED_INSTANCE_SENTINEL } from "./event-consumer-state.js";
11
- import { capPendingGapsBelowCursor, subtractSortedIdsFromRanges } from "./pending-gap-ranges.js";
11
+ import { capPendingGapsBelowCursor, mergeContiguousGapRanges, subtractSortedIdsFromRanges, } from "./pending-gap-ranges.js";
12
12
  function createRebuildCtx(registry, db, tenantId) {
13
13
  // Both surfaces throw — rebuild MUST NOT emit. We share one impl.
14
14
  const refuseAppend = async (args) => {
@@ -128,9 +128,9 @@ export async function rebuildMultiStreamProjection(mspName, deps) {
128
128
  const survivingGaps = subtractSortedIdsFromRanges(seededGaps, replayedIds);
129
129
  const cappedGaps = capPendingGapsBelowCursor(survivingGaps, lastProcessedEventId);
130
130
  const postReplayXmax = cappedGaps.length > 0 ? await selectSnapshotXmax(tx) : null;
131
- const pendingGaps = postReplayXmax === null
131
+ const pendingGaps = mergeContiguousGapRanges(postReplayXmax === null
132
132
  ? cappedGaps
133
- : cappedGaps.map((gap) => ({ ...gap, xmax: postReplayXmax }));
133
+ : cappedGaps.map((gap) => ({ ...gap, xmax: postReplayXmax })));
134
134
  await updateConsumerRebuildCursor(tx, mspName, SHARED_INSTANCE_SENTINEL, lastProcessedEventId, pendingGaps);
135
135
  await swapShadowIntoLive(tx, meta.tableName);
136
136
  });
@@ -10,3 +10,4 @@ export declare function partitionBurntGaps(gaps: readonly PendingGapEntry[], fet
10
10
  export declare function splitRangeExcludingIds(range: PendingGapEntry, excludeIds: readonly bigint[]): PendingGapEntry[];
11
11
  export declare function subtractSortedIdsFromRanges(ranges: readonly PendingGapEntry[], sortedIds: readonly bigint[]): PendingGapEntry[];
12
12
  export declare function capPendingGapsBelowCursor(gaps: readonly PendingGapEntry[], cursor: bigint): PendingGapEntry[];
13
+ export declare function mergeContiguousGapRanges(gaps: readonly PendingGapEntry[]): PendingGapEntry[];
@@ -97,3 +97,25 @@ export function capPendingGapsBelowCursor(gaps, cursor) {
97
97
  }
98
98
  return capped;
99
99
  }
100
+ // Merges ranges that touch exactly (end + 1 === next start). Keeps the row small
101
+ // when many adjacent id holes appear one pass after another. The merged xmax is
102
+ // the larger one: finality may only be claimed once the later-recorded bound has
103
+ // passed, so merging can delay it but never make it premature.
104
+ export function mergeContiguousGapRanges(gaps) {
105
+ const sorted = [...gaps].sort((a, b) => {
106
+ const diff = BigInt(a.from) - BigInt(b.from);
107
+ return diff < 0n ? -1 : diff > 0n ? 1 : 0;
108
+ });
109
+ const merged = [];
110
+ for (const gap of sorted) {
111
+ const last = merged.at(-1);
112
+ if (last && BigInt(last.to) + 1n === BigInt(gap.from)) {
113
+ const xmax = BigInt(gap.xmax) > BigInt(last.xmax) ? gap.xmax : last.xmax;
114
+ merged[merged.length - 1] = { from: last.from, to: gap.to, xmax };
115
+ }
116
+ else {
117
+ merged.push(gap);
118
+ }
119
+ }
120
+ return merged;
121
+ }
@@ -400,7 +400,7 @@ export function createSseBroadcastEventConsumer(sseBroker) {
400
400
  // exist yet has no audience to replay it to.
401
401
  startFrom: "now",
402
402
  handler: async (event) => {
403
- sseBroker.pushToChannel(tenantChannel(event.tenantId), {
403
+ await sseBroker.pushToChannel(tenantChannel(event.tenantId), {
404
404
  type: event.type,
405
405
  data: {
406
406
  id: event.aggregateId,
@@ -571,7 +571,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
571
571
  // every user behind one bad row).
572
572
  if (typeof userId !== "string" || userId.length === 0)
573
573
  return;
574
- sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
574
+ await sseBroker.publishAccessInvalidation(userId, readSessionRevokedScope(event.payload));
575
575
  }
576
576
  if (event.type === PAT_REVOKED_EVENT_TYPE) {
577
577
  const userId = event.payload["userId"];
@@ -579,7 +579,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
579
579
  // session-revoked above.
580
580
  if (typeof userId !== "string" || userId.length === 0)
581
581
  return;
582
- sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
582
+ await sseBroker.publishAccessInvalidation(userId, readPatRevokedScope(event.payload));
583
583
  }
584
584
  if (event.type === TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE ||
585
585
  event.type === TENANT_MEMBERSHIP_DELETED_EVENT_TYPE) {
@@ -588,7 +588,7 @@ export function createAccessInvalidationEventConsumer(sseBroker) {
588
588
  // reasoning as above.
589
589
  if (userId === undefined)
590
590
  return;
591
- sseBroker.publishAccessInvalidation(userId);
591
+ await sseBroker.publishAccessInvalidation(userId);
592
592
  }
593
593
  },
594
594
  };
@@ -24,7 +24,7 @@ type MemberReaderAudit = IdentitySwitchAudit & {
24
24
  };
25
25
  export declare function createGatedIdentitySwitch(callerLabel: string, caller: SessionUser | undefined, hasGrant: boolean, ungated: IdentitySwitch, audit?: IdentitySwitchAudit): IdentitySwitch;
26
26
  export declare function createGatedMemberReader(callerLabel: string, allowSystemIdentity: boolean, ungated: MemberReader, audit?: MemberReaderAudit): MemberReader;
27
- export declare function createGatedProjectionReader(callerLabel: string, hasGrant: boolean, ungated: ProjectionReader, audit?: IdentitySwitchAudit): ProjectionReader;
27
+ export declare function createGatedProjectionReader(callerLabel: string, hasGrant: boolean, ungated: ProjectionReader, audit?: IdentitySwitchAudit, projectionHasTenantColumn?: (qualifiedName: string) => boolean): ProjectionReader;
28
28
  export declare function withHookEscapeHatchGrant<TContext extends object>(context: TContext, callerLabel: string, escapeHatch: EscapeHatchDeclaration | undefined): TContext;
29
29
  export declare function bindHookEscapeHatchGrant(fn: LifecycleHookFn, label: string, escapeHatch: EscapeHatchDeclaration | undefined): LifecycleHookFn;
30
30
  export {};
@@ -104,11 +104,12 @@ export function createGatedMemberReader(callerLabel, allowSystemIdentity, ungate
104
104
  }
105
105
  // Same idea, for ctx.queryProjection — a single function rather than a pair.
106
106
  const ungatedProjectionReaderByGated = new WeakMap();
107
- // ctx.queryProjection's gate: only { unsafeAllTenants: true } is grant-checked —
108
- // a plain call always passes through, tenant-scoped by the projection's own table.
109
- export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audit) {
107
+ // ctx.queryProjection's gate: only { unsafeAllTenants: true } on a projection with a
108
+ // tenant column is grant-checked — a plain call always passes through, tenant-scoped
109
+ // by the projection's own table, and the flag is a no-op on a tenant-less one.
110
+ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audit, projectionHasTenantColumn = () => true) {
110
111
  const gated = async (qualifiedName, options) => {
111
- if (options?.unsafeAllTenants === true) {
112
+ if (options?.unsafeAllTenants === true && projectionHasTenantColumn(qualifiedName)) {
112
113
  if (!hasGrant)
113
114
  throw unsafeAllTenantsDenied(callerLabel);
114
115
  if (audit?.reason !== undefined) {
@@ -4,9 +4,11 @@
4
4
  // SELECT (sometimes two, cascade + fallback) per request, often inside an
5
5
  // open write transaction. A tenant-keyed TTL cache removes the steady-state
6
6
  // cost; dispatch-write.ts invalidates entries synchronously on
7
- // config:write:set/reset for this key, so the TTL below only covers writes
8
- // that bypass that path (migrations, seeds, direct DB edits).
9
- const DEFAULT_TTL_MS = 5 * 60 * 1000;
7
+ // config:write:set/reset for this key, but only in the process that handled
8
+ // the write. The TTL below therefore bounds how long OTHER replicas (and
9
+ // writes that bypass that path: migrations, seeds, direct DB edits) keep
10
+ // serving the old value, hence the short default.
11
+ const DEFAULT_TTL_MS = 30 * 1000;
10
12
  const DEFAULT_MAX_ENTRIES = 1000;
11
13
  export function createTenantTimezoneCache(opts = {}) {
12
14
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
@@ -2,6 +2,7 @@ import type { TenantId } from "../engine/types/identifiers.js";
2
2
  export declare const CACHE_SYNC_TOPICS: {
3
3
  readonly tenantConfig: "tenant-config";
4
4
  readonly tierAssignment: "tier-assignment";
5
+ readonly featureToggle: "feature-toggle";
5
6
  };
6
7
  export type TenantConfigSyncMessage = {
7
8
  readonly tenantId: TenantId;
@@ -1,6 +1,7 @@
1
1
  export const CACHE_SYNC_TOPICS = {
2
2
  tenantConfig: "tenant-config",
3
3
  tierAssignment: "tier-assignment",
4
+ featureToggle: "feature-toggle",
4
5
  };
5
6
  function hasOptionalStringKey(value) {
6
7
  return !("key" in value) || value.key === undefined || typeof value.key === "string";
@@ -6,4 +6,5 @@ export type RedisClientOptions = {
6
6
  readonly extra?: Readonly<Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">>;
7
7
  };
8
8
  export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
9
+ export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
9
10
  export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;
@@ -26,6 +26,13 @@ export function createRedisClient(url, options = {}) {
26
26
  maxRetriesPerRequest,
27
27
  });
28
28
  }
29
+ // Redis Pub/Sub ignores the DB index and ioredis keyPrefix, so apps or environments sharing one
30
+ // Redis instance would see each other's SSE and toggle-sync traffic. An optional prefix
31
+ // namespaces those channels; unset keeps the unprefixed channel names.
32
+ export function redisChannelPrefixFromEnv(env = process.env) {
33
+ const raw = env["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim();
34
+ return raw ? `${raw}:` : "";
35
+ }
29
36
  // Env-var reader — mirrors dbConnectionOptionsFromEnv's contract. Strict:
30
37
  // malformed values throw at boot rather than silently falling back to
31
38
  // defaults.
@@ -3,6 +3,6 @@ export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCac
3
3
  export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.js";
4
4
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
5
5
  export type { RedisClientOptions } from "./client.js";
6
- export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
6
+ export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
7
7
  export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal.js";
8
8
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -1,4 +1,4 @@
1
1
  export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, resolveCacheSyncBus, } from "./cache-sync-bus.js";
2
2
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
3
- export { createRedisClient, redisClientOptionsFromEnv } from "./client.js";
3
+ export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
4
4
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -1,6 +1,7 @@
1
1
  import { type RedisClientOptions } from "./client.js";
2
2
  export type PubSubSignal = {
3
3
  publish(channel: string, payload: unknown): void;
4
+ publishConfirmed(channel: string, payload: unknown): Promise<void>;
4
5
  onMessage(listener: (channel: string, payload: unknown) => void): void;
5
6
  onReconnect(listener: () => void): void;
6
7
  close(): Promise<void>;
@@ -92,6 +92,9 @@ export function createRedisPubSubSignal(opts) {
92
92
  console.error(`[kumiko:${opts.label}] publish failed:`, err);
93
93
  });
94
94
  },
95
+ async publishConfirmed(channel, payload) {
96
+ await publisher.publish(channel, JSON.stringify(payload));
97
+ },
95
98
  onMessage(listener) {
96
99
  listeners.add(listener);
97
100
  },
@@ -20,6 +20,14 @@ async function awaitSucceededTask(enqueued, toleratedErrorCodes = []) {
20
20
  // would fail forever.
21
21
  const REMOVE_TOLERATED_ERROR_CODES = [ErrorStatusCode.INDEX_NOT_FOUND];
22
22
  const INDEX_LIST_PAGE_SIZE = 100;
23
+ // What an unconfigured Meilisearch index reports as searchableAttributes.
24
+ const MEILI_ALL_ATTRIBUTES = "*";
25
+ function isIndexNotFound(err) {
26
+ const cause = typeof err === "object" && err !== null ? err.cause : null;
27
+ return (typeof cause === "object" &&
28
+ cause !== null &&
29
+ cause.code === ErrorStatusCode.INDEX_NOT_FOUND);
30
+ }
23
31
  // Exported for unit tests (index-name / primary-key shape) without a live Meili.
24
32
  export function meilisearchTenantIndex(prefix, tenantId) {
25
33
  return `${prefix}t${tenantId}`;
@@ -58,6 +66,24 @@ export function createMeilisearchAdapter(options) {
58
66
  // found". A failed configure is never memoized — remove the map entry (iff
59
67
  // it's still the same in-flight promise) and rethrow, so a transient
60
68
  // Meili outage doesn't permanently poison the tenant for the process.
69
+ // The configured-tenants map is process memory only: after a restart a stored explicit
70
+ // configure() must not be overwritten by the registry default, so the default is applied only
71
+ // to an index that is missing or still carries Meilisearch's factory settings.
72
+ async function indexNeedsDefaultConfig(tenantId) {
73
+ try {
74
+ const settings = await client.index(meilisearchTenantIndex(prefix, tenantId)).getSettings();
75
+ const searchable = settings.searchableAttributes ?? [MEILI_ALL_ATTRIBUTES];
76
+ const filterable = settings.filterableAttributes ?? [];
77
+ return (searchable.length === 1 &&
78
+ searchable[0] === MEILI_ALL_ATTRIBUTES &&
79
+ !filterable.includes("_type"));
80
+ }
81
+ catch (err) {
82
+ if (isIndexNotFound(err))
83
+ return true;
84
+ throw err;
85
+ }
86
+ }
61
87
  async function ensureConfigured(tenantId) {
62
88
  // skip: no registry-derived default — callers configure tenants explicitly.
63
89
  if (!defaultConfig)
@@ -68,10 +94,14 @@ export function createMeilisearchAdapter(options) {
68
94
  // skip: configured (or in flight) already for this tenant.
69
95
  return;
70
96
  }
71
- await trackConfigure(tenantId, defaultConfig);
97
+ const config = defaultConfig;
98
+ await trackConfigure(tenantId, async () => {
99
+ if (await indexNeedsDefaultConfig(tenantId))
100
+ await applyConfig(tenantId, config);
101
+ });
72
102
  }
73
- async function trackConfigure(tenantId, config) {
74
- const pending = applyConfig(tenantId, config);
103
+ async function trackConfigure(tenantId, run) {
104
+ const pending = run();
75
105
  configuredTenants.set(tenantId, pending);
76
106
  try {
77
107
  await pending;
@@ -88,7 +118,7 @@ export function createMeilisearchAdapter(options) {
88
118
  defaultConfig = config;
89
119
  },
90
120
  async configure(tenantId, config) {
91
- await trackConfigure(tenantId, config);
121
+ await trackConfigure(tenantId, () => applyConfig(tenantId, config));
92
122
  },
93
123
  async index(tenantId, doc) {
94
124
  await ensureConfigured(tenantId);
@@ -146,7 +146,7 @@ export type TestStackOptions = {
146
146
  runLogger?: (deps: {
147
147
  registry: Registry;
148
148
  db: import("../db/index.js").DbConnection;
149
- }) => Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed"> | undefined;
149
+ }) => Pick<JobRunnerOptions, "onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"> | undefined;
150
150
  };
151
151
  /** Override the event dispatcher's polling-timer interval. Default 50ms.
152
152
  * Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
@@ -157,6 +157,9 @@ export type TestStackOptions = {
157
157
  * them themselves. Default true — false pushes only projection/MSP/
158
158
  * storeTable sources, same as before fw#3102. */
159
159
  entityTables?: boolean;
160
+ /** Time source of the stack's rate-limit resolver, so tests can freeze the
161
+ * clock instead of racing the window. Default: system time. */
162
+ rateLimitNowMs?: () => number;
160
163
  /** Forwarded to buildServer's top-level `ServerOptions.trustedProxyHops`
161
164
  * — see there. Default 0. */
162
165
  trustedProxyHops?: number;
@@ -189,6 +189,8 @@ export async function setupTestStack(options) {
189
189
  getActiveTenantIds: options.jobs.getActiveTenantIds,
190
190
  }),
191
191
  ...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
192
+ ...(runLogger?.onJobQueued !== undefined && { onJobQueued: runLogger.onJobQueued }),
193
+ ...(runLogger?.onJobDropped !== undefined && { onJobDropped: runLogger.onJobDropped }),
192
194
  // Tracker fires in finally: drainJobs wakes on it, so the log row must
193
195
  // be written first, and a throwing logger must not swallow the signal.
194
196
  onJobComplete: async (jobName, jobId, ...rest) => {
@@ -268,8 +270,13 @@ export async function setupTestStack(options) {
268
270
  // `rateLimit` key, so reading it back needs the same relaxed boundary
269
271
  // extraContext itself is typed with.
270
272
  const explicitRateLimit = appContext.rateLimit;
271
- const rateLimitFallback = appContext.redis === undefined && explicitRateLimit === undefined
272
- ? createRateLimitResolver({ redis: testRedis.redis })
273
+ const needsOwnRateLimitResolver = explicitRateLimit === undefined &&
274
+ (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
275
+ const rateLimitFallback = needsOwnRateLimitResolver
276
+ ? createRateLimitResolver({
277
+ redis: testRedis.redis,
278
+ ...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
279
+ })
273
280
  : undefined;
274
281
  const server = buildServer({
275
282
  registry,
@@ -325,8 +325,13 @@ function fieldToFixture(name, field) {
325
325
  return `e2e ${name} (long-form content)`;
326
326
  case "boolean":
327
327
  return true;
328
- case "select":
329
- return field.options[0] ?? "";
328
+ case "select": {
329
+ const conditional = new Set(field.conditionalOptions?.flatMap((rule) => rule.options));
330
+ return (field.default ??
331
+ field.options.find((option) => !conditional.has(option)) ??
332
+ field.options[0] ??
333
+ "");
334
+ }
330
335
  case "multiSelect": {
331
336
  const first = field.options[0];
332
337
  return first ? [first] : [];
@@ -3,8 +3,8 @@ export type { DerivedCellRoundingTarget } from "../engine/embedded-derived.js";
3
3
  export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
4
4
  export type { ParsedRefTarget } from "../engine/parse-ref-target.js";
5
5
  export { parseRefTarget } from "../engine/parse-ref-target.js";
6
- export type { FieldsOrGroupsSection } from "../engine/screen-helpers.js";
7
- export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
6
+ export type { FieldsOrGroupsSection, UnavailableSelectOption } from "../engine/screen-helpers.js";
7
+ export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
8
8
  export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefinition, FieldDefinition, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, SelectFieldDef, SelectOptionTone, TextFieldDef, } from "../engine/types/fields.js";
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
@@ -14,7 +14,7 @@ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "
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";
17
- export { ACTION_FORM_ENTITY, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
17
+ export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
18
18
  export type { AppSchema, FeatureSchema, QualifiedContentCollection, WorkspaceSchema, } from "./app-schema.js";
19
19
  export { type Formality, formalLocaleTag } from "./formality.js";
20
20
  export type { ListRowMetaColumnType, ListRowMetaReference, ReferenceLookupSource, SystemReferenceLabel, } from "./list-row-meta.js";
@@ -22,10 +22,10 @@
22
22
  export { NO_WIDGET_FIELD_TYPES } from "@cosmicdrift/kumiko-types/fields";
23
23
  export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived.js";
24
24
  export { parseRefTarget } from "../engine/parse-ref-target.js";
25
- export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
25
+ export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
26
26
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
27
27
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
28
- export { ACTION_FORM_ENTITY, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
28
+ export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
29
29
  export { formalLocaleTag } from "./formality.js";
30
30
  export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
31
31
  export { resolveTranslationValue, translationValueOtherText, } from "./plural.js";
@@ -257,6 +257,9 @@ async function runCodemodScript(scriptPath, targetDir, repoRoot, dryRun) {
257
257
  function hasCodemod(e) {
258
258
  return typeof e.codemod === "string" && e.codemod.length > 0;
259
259
  }
260
+ function needsManualMigration(e) {
261
+ return !hasCodemod(e) || e.manualAfterCodemod === true;
262
+ }
260
263
  const MANUAL_ID_HASH_LENGTH = 8;
261
264
  const MAX_RESOLVE_REASON_LENGTH = 500;
262
265
  // The hash tells apart entries that share a version (studio has several at 0.341.0).
@@ -438,9 +441,11 @@ markerVersion, previous) {
438
441
  const codemodEntries = breaking
439
442
  .filter(hasCodemod)
440
443
  .sort((a, b) => compareVersions(a.version, b.version));
441
- const manualEntries = breaking.filter((e) => !hasCodemod(e));
444
+ const manualEntries = breaking.filter(needsManualMigration);
442
445
  for (const e of manualEntries) {
443
- out.log(` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
446
+ out.log(hasCodemod(e)
447
+ ? ` ⚠ ${e.version} · ${e.title} — codemod covers only part, manual migration still required`
448
+ : ` ⚠ ${e.version} · ${e.title} — no codemod, manual migration required`);
444
449
  }
445
450
  if (codemodEntries.length === 0) {
446
451
  out.log(breaking.length > 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.353.0",
3
+ "version": "0.354.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -187,6 +187,10 @@
187
187
  "types": "./dist/testing/real-providers.d.ts",
188
188
  "default": "./dist/testing/real-providers.js"
189
189
  },
190
+ "./internal/request-context": {
191
+ "types": "./dist/api/request-context.d.ts",
192
+ "default": "./dist/api/request-context.js"
193
+ },
190
194
  "./testing/e2e-generator": {
191
195
  "types": "./dist/testing/e2e-generator.d.ts",
192
196
  "default": "./dist/testing/e2e-generator.js"
@@ -206,8 +210,8 @@
206
210
  "./package.json": "./package.json"
207
211
  },
208
212
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.353.0",
210
- "@cosmicdrift/kumiko-types": "0.353.0",
213
+ "@cosmicdrift/kumiko-http": "0.354.1",
214
+ "@cosmicdrift/kumiko-types": "0.354.1",
211
215
  "bullmq": "^5.76.7",
212
216
  "bun-types": "^1.3.13",
213
217
  "hono": "^4.13.1",
@@ -223,7 +227,7 @@
223
227
  "zod": "^4.4.3"
224
228
  },
225
229
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.353.0",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.354.1",
227
231
  "bun-types": "^1.3.13",
228
232
  "pino-pretty": "^13.1.3"
229
233
  },
@@ -387,6 +391,10 @@
387
391
  "types": "./dist/testing/real-providers.d.ts",
388
392
  "default": "./dist/testing/real-providers.js"
389
393
  },
394
+ "./internal/request-context": {
395
+ "types": "./dist/api/request-context.d.ts",
396
+ "default": "./dist/api/request-context.js"
397
+ },
390
398
  "./testing/e2e-generator": {
391
399
  "types": "./dist/testing/e2e-generator.d.ts",
392
400
  "default": "./dist/testing/e2e-generator.js"