@cosmicdrift/kumiko-framework 0.362.0 → 0.363.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 (108) hide show
  1. package/dist/api/auth-middleware.js +7 -5
  2. package/dist/api/redis-sse-broker.js +10 -8
  3. package/dist/api/routes.js +2 -2
  4. package/dist/bun-db/query.d.ts +6 -4
  5. package/dist/bun-db/query.js +66 -92
  6. package/dist/crypto/kek-source.js +5 -3
  7. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  8. package/dist/crypto/pii-field-encryption.js +2 -2
  9. package/dist/db/apply-entity-event.js +2 -2
  10. package/dist/db/cursor.js +2 -1
  11. package/dist/db/dialect.d.ts +1 -0
  12. package/dist/db/dialect.js +28 -13
  13. package/dist/db/entity-table-from-registry.js +1 -1
  14. package/dist/db/entity-table-meta.js +5 -13
  15. package/dist/db/event-store-executor-context.js +6 -6
  16. package/dist/db/event-store-executor-read.js +9 -9
  17. package/dist/db/migrate-generator.js +5 -3
  18. package/dist/db/migrate-runner.js +31 -35
  19. package/dist/db/parent-ref-clause.js +2 -2
  20. package/dist/db/queries/backfill-pii.js +11 -9
  21. package/dist/db/queries/es-ops.js +1 -1
  22. package/dist/db/queries/event-consumer.d.ts +6 -6
  23. package/dist/db/queries/event-consumer.js +17 -17
  24. package/dist/db/queries/event-store-admin.js +6 -6
  25. package/dist/db/queries/event-store-transfer.js +2 -2
  26. package/dist/db/queries/event-store.d.ts +1 -1
  27. package/dist/db/queries/event-store.js +2 -2
  28. package/dist/db/queries/pii-backfill-state.js +2 -2
  29. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  30. package/dist/db/queries/raw-sql.js +1 -1
  31. package/dist/db/queries/test-stack.js +1 -1
  32. package/dist/db/reference-data.js +3 -5
  33. package/dist/db/table-builder.js +6 -4
  34. package/dist/db/table-introspection.d.ts +16 -0
  35. package/dist/db/table-introspection.js +76 -0
  36. package/dist/db/tenant-db.d.ts +3 -2
  37. package/dist/db/tenant-db.js +4 -10
  38. package/dist/derivatives/variant-key.js +2 -1
  39. package/dist/engine/boot-validator/entity-list-screens.js +1 -1
  40. package/dist/engine/boot-validator/pii-retention.js +1 -1
  41. package/dist/engine/boot-validator/screens.js +2 -1
  42. package/dist/engine/changeset-changes.js +3 -3
  43. package/dist/engine/define-handler.js +3 -6
  44. package/dist/engine/embedded-derived.js +10 -6
  45. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  46. package/dist/engine/factories.js +5 -6
  47. package/dist/engine/feature-ast/extractors/events.js +43 -33
  48. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  49. package/dist/engine/feature-ast/patcher.js +3 -3
  50. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
  51. package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
  52. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  53. package/dist/engine/feature-ast/render.js +6 -3
  54. package/dist/engine/feature-changelog.d.ts +2 -1
  55. package/dist/engine/feature-changelog.js +8 -6
  56. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  57. package/dist/engine/feature-config-events-jobs.js +20 -5
  58. package/dist/engine/feature-ui-extensions.js +2 -3
  59. package/dist/engine/index.d.ts +2 -1
  60. package/dist/engine/index.js +1 -0
  61. package/dist/engine/ownership.js +7 -20
  62. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  63. package/dist/engine/registry-ingest.d.ts +2 -1
  64. package/dist/engine/registry-ingest.js +85 -2
  65. package/dist/engine/registry-validate.d.ts +1 -0
  66. package/dist/engine/registry-validate.js +38 -8
  67. package/dist/engine/registry.js +2 -1
  68. package/dist/engine/schema-builder.js +1 -3
  69. package/dist/engine/soft-delete-cleanup.js +2 -1
  70. package/dist/engine/steps/call-feature.js +1 -3
  71. package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
  72. package/dist/engine/template-variables.d.ts +29 -0
  73. package/dist/engine/template-variables.js +77 -0
  74. package/dist/engine/tier-resolver-extension.js +2 -3
  75. package/dist/engine/types/index.d.ts +2 -2
  76. package/dist/engine/validate-projection-allowlist.js +12 -13
  77. package/dist/engine/where-rule-lint.js +8 -7
  78. package/dist/errors/reasons.d.ts +1 -0
  79. package/dist/errors/reasons.js +2 -0
  80. package/dist/files/file-routes.js +1 -1
  81. package/dist/files/local-provider.js +2 -1
  82. package/dist/i18n/index.d.ts +1 -1
  83. package/dist/i18n/index.js +1 -1
  84. package/dist/i18n/mail-registry.d.ts +2 -0
  85. package/dist/i18n/mail-registry.js +7 -0
  86. package/dist/jobs/job-runner.js +5 -5
  87. package/dist/pipeline/dispatch-query.js +2 -3
  88. package/dist/pipeline/dispatch-shared.js +45 -14
  89. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  90. package/dist/pipeline/index.d.ts +1 -0
  91. package/dist/pipeline/index.js +1 -0
  92. package/dist/pipeline/member-read-only-transaction.js +1 -1
  93. package/dist/pipeline/notify-scope.d.ts +13 -0
  94. package/dist/pipeline/notify-scope.js +32 -0
  95. package/dist/pipeline/system-hooks.js +2 -1
  96. package/dist/pipeline/system-identity-switch.js +2 -2
  97. package/dist/search/meilisearch-adapter.js +3 -4
  98. package/dist/secrets/stored-envelope.js +7 -7
  99. package/dist/stack/push-entity-projection-tables.js +2 -3
  100. package/dist/stack/table-helpers.js +10 -26
  101. package/dist/stack/test-stack.js +2 -5
  102. package/dist/testing/multipart-helper.d.ts +0 -6
  103. package/dist/testing/multipart-helper.js +5 -5
  104. package/dist/testing/without-ambient-temporal.js +4 -4
  105. package/dist/utils/is-plain-object.d.ts +1 -2
  106. package/dist/utils/is-plain-object.js +1 -4
  107. package/package.json +4 -4
  108. package/src/changes.json +44 -0
@@ -27,6 +27,7 @@ import { executeQuery } from "./dispatch-query.js";
27
27
  import { executeWrite } from "./dispatch-write.js";
28
28
  import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
29
29
  import { createMemberReaderFn } from "./member-reader.js";
30
+ import { createDeferringNotify } from "./notify-scope.js";
30
31
  import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
31
32
  import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
32
33
  // Framework/pipeline stays bundled-features-free, so this can't import the
@@ -47,8 +48,10 @@ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
47
48
  // dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
48
49
  // appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
49
50
  export function resolveDbSource(ctx, tx) {
50
- const { appContext: context } = ctx;
51
- return tx ?? context.db; // @cast-boundary db-operator
51
+ return tx ?? resolveAppPoolDb(ctx);
52
+ }
53
+ function resolveAppPoolDb(ctx) {
54
+ return ctx.appContext.db; // @cast-boundary db-operator
52
55
  }
53
56
  // ctx.appendEvent — append a domain event onto a specific aggregate stream
54
57
  // in the current tx, then fire matching inline projections. Core logic
@@ -248,7 +251,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
248
251
  // that is meant to outlive the request. Computed before `systemDb` below
249
252
  // so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
250
253
  // this same TenantDb instead of a second, independently-built one.
251
- const outsideTxSource = ctx.outsideTransactionDb ?? resolveDbSource(ctx, undefined);
254
+ const outsideTxSource = ctx.outsideTransactionDb ?? resolveAppPoolDb(ctx);
252
255
  const rawDbOutsideTransaction = outsideTxSource
253
256
  ? buildTenantScopedDb(outsideTxSource, undefined)
254
257
  : undefined;
@@ -279,9 +282,40 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
279
282
  userId: user.id,
280
283
  ...(reqCtx && { requestId: reqCtx.requestId }),
281
284
  });
282
- const notify = context._notifyFactory
283
- ? context._notifyFactory(user, user.tenantId, isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined)
285
+ // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
286
+ // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
287
+ const bridgeSink = afterCommitHooks ?? [];
288
+ // Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
289
+ const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
290
+ const scheduleAfterCommit = (hook) => {
291
+ bridgeSink.push(hook);
292
+ };
293
+ const notifyFactory = context._notifyFactory;
294
+ const notifyJobDispatcher = isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined;
295
+ // Inside the write tx the app pool's slots can all be held by waiting write txs, so the
296
+ // delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
297
+ const notifyInTransactionFactory = notifyFactory && tx !== undefined
298
+ ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
284
299
  : undefined;
300
+ const notify = buildNotify();
301
+ function buildNotify() {
302
+ if (!notifyFactory)
303
+ return undefined;
304
+ const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
305
+ if (!notifyInTransactionFactory)
306
+ return notifyAfterCommit;
307
+ const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
308
+ // A rolled-back write must leave no notification behind.
309
+ if (!hasLiveCommitSink)
310
+ return notifyInTransaction;
311
+ return createDeferringNotify({
312
+ registry,
313
+ tenantId: user.tenantId,
314
+ notifyAfterCommit,
315
+ notifyNow: notifyInTransaction,
316
+ scheduleAfterCommit,
317
+ });
318
+ }
285
319
  // Mirror notify: only built when the config feature wired its factory.
286
320
  const config = context._configAccessorFactory && db
287
321
  ? context._configAccessorFactory({
@@ -361,9 +395,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
361
395
  const metricsFor = meter
362
396
  ? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
363
397
  : () => createNoopMetricsHandle();
364
- // Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
365
- // queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
366
- const bridgeSink = afterCommitHooks ?? [];
367
398
  // A failed inner write has already appended its event and projections when a later
368
399
  // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
369
400
  // failure result. The savepoint makes the failure undo exactly the inner write; the
@@ -374,7 +405,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
374
405
  const innerAfterCommitHooks = [];
375
406
  try {
376
407
  const result = await runInSavepoint(tx, async (savepointTx) => {
377
- const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary driver savepoint handle is a transaction handle
408
+ const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary db-runner savepoint handle is a transaction handle
378
409
  innerAfterCommitHooks);
379
410
  if (isFailedWriteResult(innerResult))
380
411
  throw new NestedWriteRollback(innerResult);
@@ -396,9 +427,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
396
427
  const writeAsUser = async (asUser, targetType, payload) => isPostCommit
397
428
  ? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
398
429
  : executeNestedWriteInSavepoint(asUser, targetType, payload);
399
- const scheduleAfterCommit = (hook) => {
400
- bridgeSink.push(hook);
401
- };
402
430
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
403
431
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
404
432
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
@@ -733,9 +761,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
733
761
  const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
734
762
  // The payload-bucket digest is dispatcher-internal; handlers must not be
735
763
  // able to compute bucket keys.
736
- const { _rateLimitPayloadDigest: _omitPayloadDigest, ...contextForHandler } = context;
764
+ const { _rateLimitPayloadDigest: _omitPayloadDigest,
765
+ // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
766
+ _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
737
767
  const handlerContext = {
738
768
  ...contextForHandler,
769
+ ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
739
770
  registry,
740
771
  db: exposedDb,
741
772
  dbOutsideTransaction,
@@ -781,7 +812,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
781
812
  _userId: user.id,
782
813
  _tenantId: user.tenantId,
783
814
  _handlerType: type,
784
- scheduleAfterCommit,
815
+ ...(hasLiveCommitSink && { scheduleAfterCommit }),
785
816
  ...(includeDeleted && { includeDeleted: true }),
786
817
  ...bridge,
787
818
  }; // @cast-boundary engine-bridge
@@ -57,8 +57,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
57
57
  const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
58
58
  if (cooldownElapsed && state.rearmCount < maxRearmCount) {
59
59
  const rearmed = await rearmDeadConsumer(tx, name, instanceId);
60
- const rearmedState = rearmed &&
61
- coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
60
+ const rearmedState = rearmed && coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
62
61
  if (rearmedState)
63
62
  return { state: rearmedState, skip: null };
64
63
  }
@@ -149,7 +148,7 @@ export async function markProcessing(tx, name, instanceId) {
149
148
  export async function fetchPendingEvents(tx, cursor, batchSize, pendingRanges = []) {
150
149
  const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
151
150
  const info = extractTableInfo(eventsTable);
152
- return rawRows.map((row) => coerceRow(row, info)); // @cast-boundary db-row
151
+ return rawRows.map((row) => coerceRow(row, info));
153
152
  }
154
153
  // Deliver events to the consumer's handler in events.id order, or — when
155
154
  // the consumer wires a batchHandler — through it in one call first. Halt-
@@ -30,6 +30,7 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
30
30
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
31
31
  export type { MspRebuildDeps } from "./msp-rebuild.js";
32
32
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
33
+ export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
33
34
  export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
34
35
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
35
36
  export type { ProjectionStatus } from "./projection-state.js";
@@ -15,6 +15,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
15
15
  export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
16
16
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
17
17
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
18
+ export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
18
19
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
19
20
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
20
21
  export { runProjectionsForEvent } from "./projections-runner.js";
@@ -34,7 +34,7 @@ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, f
34
34
  await runInSavepoint(tx, async (sp) => {
35
35
  // kumiko-lint-ignore raw-sql transaction characteristic, no query helper
36
36
  await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
37
- // @cast-boundary driver savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
37
+ // @cast-boundary db-runner savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
38
38
  settled.outcome = { value: await fn(sp) };
39
39
  // Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
40
40
  throw new SavepointDiscard();
@@ -0,0 +1,13 @@
1
+ import type { TenantId } from "../engine/types/identifiers.js";
2
+ import type { NotifyFn, NotifyOptions, Registry } from "../engine/types/index.js";
3
+ import type { AfterCommitHook } from "./dispatcher-utils.js";
4
+ export declare function assertNotifyTargetsOwnTenant(options: NotifyOptions, tenantId: TenantId): void;
5
+ type DeferringNotifyDeps = {
6
+ readonly registry: Pick<Registry, "getAllNotifications"> | undefined;
7
+ readonly tenantId: TenantId;
8
+ readonly notifyAfterCommit: NotifyFn;
9
+ readonly notifyNow: NotifyFn;
10
+ readonly scheduleAfterCommit: (hook: AfterCommitHook) => void;
11
+ };
12
+ export declare function createDeferringNotify(deps: DeferringNotifyDeps): NotifyFn;
13
+ export {};
@@ -0,0 +1,32 @@
1
+ import { assertNotificationVariables } from "../engine/template-variables.js";
2
+ import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
3
+ function isTenantBroadcast(to) {
4
+ return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
5
+ }
6
+ export function assertNotifyTargetsOwnTenant(options, tenantId) {
7
+ const { to } = options;
8
+ if (isTenantBroadcast(to) && to.tenant !== tenantId) {
9
+ throw new AccessDeniedError({
10
+ message: "ctx.notify can only broadcast to the caller's own tenant",
11
+ details: { reason: FrameworkReasons.notifyForeignTenantBroadcast },
12
+ });
13
+ }
14
+ }
15
+ // Without `immediate`, a notify inside a write transaction waits for the commit so a
16
+ // rolled-back write leaves no notification behind.
17
+ export function createDeferringNotify(deps) {
18
+ const { registry, tenantId, notifyAfterCommit, notifyNow, scheduleAfterCommit } = deps;
19
+ async function notifyDeferringUntilCommit(notificationType, options) {
20
+ assertNotifyTargetsOwnTenant(options, tenantId);
21
+ // Fail the write now: after the commit the problem would only surface in the render job.
22
+ if (registry)
23
+ assertNotificationVariables(registry, notificationType, options.data);
24
+ if (options.immediate === true)
25
+ return notifyNow(notificationType, options);
26
+ scheduleAfterCommit(async () => {
27
+ await notifyAfterCommit(notificationType, options);
28
+ });
29
+ return { kind: "deferred" };
30
+ }
31
+ return notifyDeferringUntilCommit;
32
+ }
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { readNonEmptyStringArray, } from "../api/sse-broker.js";
2
3
  import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
3
4
  import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
@@ -520,7 +521,7 @@ const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
520
521
  const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
521
522
  function readUserIdFromPreviousSnapshot(payload) {
522
523
  const previous = payload["previous"];
523
- if (typeof previous !== "object" || previous === null)
524
+ if (!isPlainObject(previous))
524
525
  return undefined;
525
526
  const userId = previous["userId"];
526
527
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
@@ -124,14 +124,14 @@ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audi
124
124
  function readIdentitySwitchFn(context, key) {
125
125
  if (!(key in context))
126
126
  return undefined;
127
- const value = context[key];
127
+ const value = Reflect.get(context, key);
128
128
  return typeof value === "function" ? value : undefined; // @cast-boundary engine-bridge — checked via typeof above
129
129
  }
130
130
  // context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
131
131
  function readDbLikeValue(context, key) {
132
132
  if (!(key in context))
133
133
  return undefined;
134
- const value = context[key];
134
+ const value = Reflect.get(context, key);
135
135
  return typeof value === "object" && value !== null ? value : undefined;
136
136
  }
137
137
  function unavailableIdentitySwitchFn(callerLabel, kind) {
@@ -1,3 +1,4 @@
1
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { ErrorStatusCode, Meilisearch } from "meilisearch";
2
3
  // meilisearch's waitTask() RESOLVES (never rejects) once the server task
3
4
  // reaches a terminal state, even "failed" — a rejected doc (e.g. an id over
@@ -23,10 +24,8 @@ const INDEX_LIST_PAGE_SIZE = 100;
23
24
  // What an unconfigured Meilisearch index reports as searchableAttributes.
24
25
  const MEILI_ALL_ATTRIBUTES = "*";
25
26
  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);
27
+ const cause = typeof err === "object" && err !== null && "cause" in err ? err.cause : null;
28
+ return errorCodeOf(cause) === ErrorStatusCode.INDEX_NOT_FOUND;
30
29
  }
31
30
  // Exported for unit tests (index-name / primary-key shape) without a live Meili.
32
31
  export function meilisearchTenantIndex(prefix, tenantId) {
@@ -2,6 +2,7 @@
2
2
  // jsonb columns (secrets feature) or JSON-stringified TEXT columns
3
3
  // (EnvelopeCipher for config values / entity fields). One wire shape for
4
4
  // every envelope-encrypted store in the framework.
5
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
5
6
  export function encodeStoredEnvelope(envelope) {
6
7
  return {
7
8
  ciphertext: envelope.ciphertext.toString("base64"),
@@ -21,12 +22,11 @@ export function decodeStoredEnvelope(stored) {
21
22
  };
22
23
  }
23
24
  export function isStoredEnvelope(value) {
24
- if (!value || typeof value !== "object")
25
+ if (!isPlainObject(value))
25
26
  return false;
26
- const v = value; // @cast-boundary parse-boundary after typeof check
27
- return (typeof v["ciphertext"] === "string" &&
28
- typeof v["iv"] === "string" &&
29
- typeof v["authTag"] === "string" &&
30
- typeof v["encryptedDek"] === "string" &&
31
- typeof v["kekVersion"] === "number");
27
+ return (typeof value["ciphertext"] === "string" &&
28
+ typeof value["iv"] === "string" &&
29
+ typeof value["authTag"] === "string" &&
30
+ typeof value["encryptedDek"] === "string" &&
31
+ typeof value["kekVersion"] === "number");
32
32
  }
@@ -1,5 +1,5 @@
1
- import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { columnNamesOf, tableExists } from "../db/schema-inspection.js";
2
+ import { schemaTableName } from "../db/table-introspection.js";
3
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);
@@ -29,8 +29,7 @@ export async function pushEntityProjectionTables(stack, registry) {
29
29
  if (seen.has(proj.table))
30
30
  continue;
31
31
  seen.add(proj.table);
32
- const tableRec = proj.table;
33
- const physical = tableRec[KUMIKO_NAME_SYMBOL];
32
+ const physical = schemaTableName(proj.table) ?? tableToMeta(proj.table).tableName;
34
33
  if (!(await tableExists(stack.db, `public.${physical}`))) {
35
34
  missing[projName] = proj.table;
36
35
  continue;
@@ -1,20 +1,19 @@
1
- import { KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
2
1
  import { pgTypeToSqlType } from "../db/dialect.js";
3
2
  import { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement, } from "../db/queries/ddl.js";
4
3
  import { truncateTablesRestartIdentity } from "../db/queries/test-stack.js";
5
4
  import { renderTableDdl } from "../db/render-ddl.js";
6
5
  import { tableExists } from "../db/schema-inspection.js";
7
6
  import { buildEntityTable, toTableName } from "../db/table-builder.js";
7
+ import { asEntityTableMeta, schemaTableName } from "../db/table-introspection.js";
8
8
  function tableNameOf(table) {
9
9
  if (typeof table !== "object" || table === null) {
10
10
  throw new Error("table-helpers: table is not a SchemaTable object");
11
11
  }
12
- const rec = table;
13
- if (typeof rec[KUMIKO_NAME_SYMBOL] === "string")
14
- return rec[KUMIKO_NAME_SYMBOL];
15
- if (typeof rec.tableName === "string") {
16
- return rec.tableName;
17
- }
12
+ const symbolName = schemaTableName(table);
13
+ if (symbolName !== undefined)
14
+ return symbolName;
15
+ if ("tableName" in table && typeof table.tableName === "string")
16
+ return table.tableName;
18
17
  throw new Error("table-helpers: table has no name");
19
18
  }
20
19
  /**
@@ -36,26 +35,11 @@ export async function unsafeEnsureEntityTable(db, entity, entityName) {
36
35
  // Tables produced by the native dialect already carry EntityTableMeta-shape
37
36
  // (source/columns/indexes). renderTableDdl converts that to CREATE TABLE +
38
37
  // CREATE INDEX statements executed via db/queries/test-stack.
39
- function isMetaShape(v) {
40
- return (typeof v === "object" &&
41
- v !== null &&
42
- typeof v.tableName === "string" &&
43
- Array.isArray(v.columns) &&
44
- Array.isArray(v.indexes) &&
45
- (v.source === "managed" || v.source === "unmanaged"));
46
- }
47
38
  export function tableToMeta(table) {
48
- // table() spreads column handles as enumerable props, so a field named
49
- // `columns`/`tableName`/`source`/… shadows the matching meta key — read the
50
- // canonical meta from the unshadowable symbol when present.
51
- if (table !== null && typeof table === "object") {
52
- const fromSymbol = table[KUMIKO_META_SYMBOL];
53
- if (isMetaShape(fromSymbol))
54
- return fromSymbol;
55
- }
56
- if (isMetaShape(table))
57
- return table;
58
- throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
39
+ const meta = asEntityTableMeta(table);
40
+ if (!meta)
41
+ throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
42
+ return meta;
59
43
  }
60
44
  /** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
61
45
  export async function addMissingColumns(db, tableName, columns) {
@@ -1,3 +1,4 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { buildServer } from "../api/server.js";
2
3
  import { createSseBroker } from "../api/sse-broker.js";
3
4
  import { validateBoot, validateNavBootSubset } from "../engine/boot-validator/index.js";
@@ -269,11 +270,7 @@ export async function setupTestStack(options) {
269
270
  // always-real `testRedis.redis`, independent of whatever extraContext
270
271
  // did to `appContext.redis` — an explicit `context.rateLimit` from
271
272
  // extraContext still wins (same precedence buildServer documents).
272
- // @cast-boundary extraContext is declared Record<string, unknown> (late,
273
- // dynamic extension point) — appContext's inferred type doesn't carry a
274
- // `rateLimit` key, so reading it back needs the same relaxed boundary
275
- // extraContext itself is typed with.
276
- const explicitRateLimit = appContext.rateLimit;
273
+ const explicitRateLimit = propertyOf(appContext, "rateLimit");
277
274
  const needsOwnRateLimitResolver = explicitRateLimit === undefined &&
278
275
  (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
279
276
  const rateLimitFallback = needsOwnRateLimitResolver
@@ -1,9 +1,3 @@
1
- /**
2
- * Serializes a FormData instance to multipart/form-data bytes.
3
- *
4
- * Returns the encoded body and the Content-Type header value (including the
5
- * generated boundary). Pass both directly to app.request or fetch.
6
- */
7
1
  export declare function buildMultipartBody(fd: FormData): Promise<{
8
2
  body: BodyInit;
9
3
  contentType: string;
@@ -19,6 +19,7 @@
19
19
  * Returns the encoded body and the Content-Type header value (including the
20
20
  * generated boundary). Pass both directly to app.request or fetch.
21
21
  */
22
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
22
23
  export async function buildMultipartBody(fd) {
23
24
  const boundary = `KumikoBnd${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
24
25
  const enc = new TextEncoder();
@@ -64,12 +65,11 @@ export function patchFileInstanceofForBunTest() {
64
65
  return;
65
66
  Object.defineProperty(File, Symbol.hasInstance, {
66
67
  value(instance) {
67
- if (typeof instance !== "object" || instance === null)
68
+ if (!isPlainObject(instance))
68
69
  return false;
69
- const f = instance;
70
- return (typeof f["name"] === "string" &&
71
- typeof f["size"] === "number" &&
72
- typeof f["arrayBuffer"] === "function");
70
+ return (typeof instance["name"] === "string" &&
71
+ typeof instance["size"] === "number" &&
72
+ typeof instance["arrayBuffer"] === "function");
73
73
  },
74
74
  configurable: true,
75
75
  });
@@ -3,15 +3,15 @@
3
3
  * Used by ambient-independence regression tests (kumiko-framework#1525/#1550).
4
4
  */
5
5
  export async function withoutAmbientTemporal(fn) {
6
- const savedGlobal = globalThis.Temporal;
7
- delete globalThis.Temporal;
6
+ const savedGlobal = Reflect.get(globalThis, "Temporal");
7
+ Reflect.deleteProperty(globalThis, "Temporal");
8
8
  try {
9
9
  return await fn();
10
10
  }
11
11
  finally {
12
12
  if (savedGlobal === undefined)
13
- delete globalThis.Temporal;
13
+ Reflect.deleteProperty(globalThis, "Temporal");
14
14
  else
15
- globalThis.Temporal = savedGlobal;
15
+ Reflect.set(globalThis, "Temporal", savedGlobal);
16
16
  }
17
17
  }
@@ -1,2 +1 @@
1
- /** Non-null object that is not an array — shared guard for deep-merge and AST extractors. */
2
- export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
1
+ export { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
@@ -1,4 +1 @@
1
- /** Non-null object that is not an array — shared guard for deep-merge and AST extractors. */
2
- export function isPlainObject(value) {
3
- return typeof value === "object" && value !== null && !Array.isArray(value);
4
- }
1
+ export { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.362.0",
3
+ "version": "0.363.0",
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>",
@@ -210,8 +210,8 @@
210
210
  "./package.json": "./package.json"
211
211
  },
212
212
  "dependencies": {
213
- "@cosmicdrift/kumiko-http": "0.362.0",
214
- "@cosmicdrift/kumiko-types": "0.362.0",
213
+ "@cosmicdrift/kumiko-http": "0.363.0",
214
+ "@cosmicdrift/kumiko-types": "0.363.0",
215
215
  "bullmq": "^5.76.7",
216
216
  "bun-types": "^1.3.13",
217
217
  "hono": "^4.13.1",
@@ -227,7 +227,7 @@
227
227
  "zod": "^4.4.3"
228
228
  },
229
229
  "devDependencies": {
230
- "@cosmicdrift/kumiko-dispatcher-live": "0.362.0",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.363.0",
231
231
  "bun-types": "^1.3.13",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,48 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "improvement",
5
+ "title": "Fewer unchecked casts in bundled features, dev-server, server-runtime, testing and cli",
6
+ "detail": "Casts are replaced by shared type guards, typed query rows and direct Bun typings. Behavior stays the same, except that a tier-resolver registration without a build() factory now fails with a clear error at boot, the kumiko CLI prints the thrown value of non-Error failures instead of \"undefined\"."
7
+ },
8
+ {
9
+ "version": "0.363.0",
10
+ "type": "improvement",
11
+ "title": "Remove unknown cast-boundary reasons and share propertyOf",
12
+ "detail": "Framework internals read untyped properties through the shared propertyOf from kumiko-types instead of private copies and casts. The as-casts guard knows four more boundary reasons (phantom-brand, third-party-type-gap, dom-event-target, feature-exports) and normalizes whitespace in cast targets so re-indenting a multi-line cast no longer counts as a regression. A stored inbound-mail cursor that is not a JSON object is now treated like a missing cursor."
13
+ },
14
+ {
15
+ "version": "0.363.0",
16
+ "type": "breaking",
17
+ "title": "ctx.notify in write handlers delivers after commit",
18
+ "detail": "Inside a write handler's transaction ctx.notify only records the notification and delivers it once the write has committed, so a rolled-back write sends nothing and leaves no attempt event. Delivery no longer takes a connection from the app pool while the write holds one, which deadlocked at pool max N with N parallel writes. With immediate: true the call stays synchronous and runs its DB work on the outside-transaction pool.",
19
+ "migration": "NotifyResult is now a union, NotifyDispatched ({ kind: \"dispatched\", deliveries }) or NotifyDeferred ({ kind: \"deferred\" }). In a write handler without immediate: true ctx.notify returns deferred and delivers after commit (a rollback means no notification). Code that reads the result (for example a \"send test message\" handler showing sent/failed) sets immediate: true, which delivers synchronously on the outside-transaction pool and returns dispatched; narrow on kind before reading deliveries. Errors such as a missing idempotency Redis, a tenant broadcast without the tenant feature or an unknown notification type no longer throw in a deferred write handler, they land in the afterCommit log. Notifications from jobs, from seeding and from handlers without a transaction stay synchronous. The boot default wires tenantUserIdsQuery for to: { tenant } when the tenant feature is mounted, so an app-side tenantUserIdsQuery wiring can go. Custom NotifyFactory implementations receive an optional 4th parameter deliveryDb, and DeliveryService.notify an optional 6th."
20
+ },
21
+ {
22
+ "version": "0.363.0",
23
+ "type": "improvement",
24
+ "title": "Shared type guards and fewer unchecked casts in framework internals",
25
+ "detail": "\"@cosmicdrift/kumiko-types/type-guards\" exports isPlainObject, isOneOf and errorCodeOf. The framework replaces many unchecked as-casts with narrowing, generic query rows and table-introspection helpers. encryptPiiFieldValues is generic over the row and returns PiiEncryptedRow<TRow>, so callers read encrypted fields without casts. Behavior stays the same."
26
+ },
27
+ {
28
+ "version": "0.363.0",
29
+ "type": "improvement",
30
+ "title": "r.notification declares variables, template and meta; applyBootSeeds seeds them",
31
+ "detail": "r.notification accepts variables (typed with templateVariable.text, link, money, date and list), template (per locale body, document html) and meta (per locale subject and more). Boot validation requires a kebab-case name, a meta.<locale>.subject for every template locale and valid placeholder names. templateVariablesToZod turns the variables into a Zod schema. applyBootSeeds calls seedNotificationTemplates, which writes the text as system templates of kind mail and fails loudly when notifications declare a template but the template-resolver feature is not mounted."
32
+ },
33
+ {
34
+ "version": "0.363.0",
35
+ "type": "improvement",
36
+ "title": "r.notification works without trigger and ships localized templates",
37
+ "detail": "trigger, recipient and data are optional and must be declared together, a notification without trigger is sent explicitly through ctx.notify. localizedTemplate groups body and meta per locale and is mutually exclusive with template and meta. The variable names link, money and date are reserved for template helpers. ctx.notify validates the data against the declared variables and fails the write on a missing required variable. New exports are listMailLocales, hasMailTranslationKey, assertNotificationVariables and NotificationLocalizedTemplate."
38
+ },
39
+ {
40
+ "version": "0.363.0",
41
+ "type": "breaking",
42
+ "title": "ctx.notify rejects a broadcast to another tenant",
43
+ "detail": "A notify with to: { tenant } now throws an AccessDeniedError (reason notify_foreign_tenant_broadcast) unless the tenant is the caller's own, before any delivery work. In a write handler it fails synchronously instead of landing in the afterCommit log. Notifications fired from seed hooks during self-signup no longer use the app pool inside the signup transaction, which deadlocked at pool max N with N parallel signups; they deliver after commit. ctx.scheduleAfterCommit is now absent in query bridges and post-commit contexts, where hooks pushed to it never ran; callers that check for it (cache-sync announce, set-tenant-tier onAssigned, form-draft discard, seed hooks) now run inline there instead of being dropped.",
44
+ "migration": "to: { tenant } must be the tenant of the caller (user.tenantId of the handler or job context), otherwise notify throws; in a write handler the throw is synchronous. Seed-hook notifications in the signup transaction run after commit and return { kind: \"deferred\" } (immediate: true runs synchronously on the outside-transaction pool, which cannot see rows the signup transaction has not committed yet, so a tenant broadcast there finds no new member). No known consumer broadcasts to a foreign tenant."
45
+ },
2
46
  {
3
47
  "version": "0.362.0",
4
48
  "type": "fix",