@cosmicdrift/kumiko-framework 0.364.0 → 0.365.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 (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. package/src/changes.json +323 -0
@@ -9,13 +9,13 @@ import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index
9
9
  import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
10
10
  import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
11
11
  import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
12
- import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
12
+ import { loadAggregate, loadAggregateAsOf, loadAggregateForWriting, } from "../event-store/event-store.js";
13
13
  import { effectiveSnapshotGeneration, loadAggregateWithSnapshot, saveSnapshot, } from "../event-store/snapshot.js";
14
14
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster.js";
15
15
  import { createFileContext } from "../files/file-handle.js";
16
16
  import { DEFAULT_LOCALE, isValidLocaleTag } from "../i18n/request-locale.js";
17
17
  import { createFallbackLogger } from "../logging/utils.js";
18
- import { createEscapeHatchReporter, createMetricsHandle, createNoopMetricsHandle, createSafeMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
18
+ import { createEscapeHatchReporter, createMetricsForFactory, createMetricsHandle, createNoopMetricsHandle, emitDispatcherError, emitDispatcherHandler, getFallbackTracer, observabilityContext, } from "../observability/index.js";
19
19
  import { buildBucketKey, buildPayloadBucketKey, normalizePayloadBucketValue, } from "../rate-limit/index.js";
20
20
  import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
21
21
  import { isPlainObject } from "../utils/is-plain-object.js";
@@ -257,10 +257,14 @@ export function applyBeforeTransactionReadOnly(handlerContext) {
257
257
  ...denyTransactionBoundSurfaces(handlerContext),
258
258
  dbOutsideTransaction: undefined,
259
259
  outsideTransaction: undefined,
260
- forTenant: undefined,
261
260
  };
262
261
  }
263
- export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction") {
262
+ export async function buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, includeDeleted, transactionScope = tx ? "inTransaction" : "withoutTransaction",
263
+ // A query bridge has no commit sink of its own, but ctx.notify inside it must still wait for
264
+ // the outer write's commit: the outer sink is lent to notify only, not to scheduleAfterCommit.
265
+ outerNotifySink,
266
+ // beforeTransaction phase: ctx.forTenant stays available but every target db denies writes.
267
+ readOnlyForTenant = false) {
264
268
  const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
265
269
  const isSystem = registry.isHandlerSystemScoped(type);
266
270
  // The outer dispatcher receives a DbConnection from the server/stack;
@@ -358,23 +362,30 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
358
362
  const notifyInTransactionFactory = notifyFactory && tx !== undefined
359
363
  ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
360
364
  : undefined;
365
+ const notifyAfterCommitFactory = notifyFactory
366
+ ? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, undefined, context._fileProviderResolver)
367
+ : undefined;
361
368
  const notify = buildNotify();
362
369
  function buildNotify() {
363
- if (!notifyFactory)
370
+ if (!notifyAfterCommitFactory)
364
371
  return undefined;
365
- const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
372
+ const notifyAfterCommit = notifyAfterCommitFactory(user, user.tenantId);
366
373
  if (!notifyInTransactionFactory)
367
374
  return notifyAfterCommit;
368
375
  const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
369
- // A rolled-back write must leave no notification behind.
370
- if (!hasLiveCommitSink)
376
+ // A rolled-back write must leave no notification behind. Without any commit to wait for (a
377
+ // post-commit context) delivery stays immediate.
378
+ const notifyCommitSink = hasLiveCommitSink ? bridgeSink : outerNotifySink;
379
+ if (notifyCommitSink === undefined)
371
380
  return notifyInTransaction;
372
381
  return createDeferringNotify({
373
382
  registry,
374
383
  tenantId: user.tenantId,
375
384
  notifyAfterCommit,
376
385
  notifyNow: notifyInTransaction,
377
- scheduleAfterCommit,
386
+ scheduleAfterCommit: (hook) => {
387
+ notifyCommitSink.push(hook);
388
+ },
378
389
  });
379
390
  }
380
391
  // Mirror notify: only built when the config feature wired its factory.
@@ -417,7 +428,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
417
428
  if (targetTenantId === user.tenantId && exposedDb) {
418
429
  // Only perCommand handlers lose forTenant writes; reservations keep their pre-transaction contract.
419
430
  const ownTenantDb = denyDbWrites && !isPerCommand
420
- ? buildTenantScopedDb(dbSource, reqCtx?.signal)
431
+ ? buildTenantScopedDb(dbSource, reqCtx?.signal, undefined, readOnlyForTenant)
421
432
  : exposedDb;
422
433
  return { db: ownTenantDb, streamTenantId: undefined };
423
434
  }
@@ -427,7 +438,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
427
438
  });
428
439
  }
429
440
  return {
430
- db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && isPerCommand),
441
+ db: buildTenantScopedDb(dbSource, reqCtx?.signal, targetTenantId, denyDbWrites && (isPerCommand || readOnlyForTenant)),
431
442
  streamTenantId: targetTenantId,
432
443
  };
433
444
  }
@@ -455,16 +466,13 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
455
466
  const metrics = meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
456
467
  // ctx.metricsFor(featureName) — shared/library code binds to a feature
457
468
  // name of its own choosing instead of the dispatching handler's
458
- // (framework#1844). Unregistered names no-op rather than throw, see
459
- // createSafeMetricsHandle.
460
- const metricsFor = meter
461
- ? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
462
- : () => createNoopMetricsHandle();
469
+ // (framework#1844). Unregistered names no-op rather than throw.
470
+ const metricsFor = createMetricsForFactory(meter);
463
471
  // A failed inner write has already appended its event and projections when a later
464
472
  // step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
465
473
  // failure result. The savepoint makes the failure undo exactly the inner write; the
466
474
  // inner afterCommit hooks only reach the outer sink if it succeeded.
467
- const executeNestedWriteInSavepoint = async (asUser, targetType, payload) => {
475
+ const runNestedWriteInSavepoint = async (asUser, targetType, payload) => {
468
476
  if (registry.getWriteHandler(targetType)?.transaction === "perCommand") {
469
477
  return writeFailure(perCommandNestedDenied(targetType));
470
478
  }
@@ -488,6 +496,15 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
488
496
  throw e;
489
497
  }
490
498
  };
499
+ // Savepoints on one connection are not serialized by the driver: two parallel nested writes would
500
+ // interleave their statements and a ROLLBACK TO of the first would also discard the second's rows.
501
+ // Writes nested one level deeper run on their own savepoint handle and therefore their own chain.
502
+ let nestedWriteChain = Promise.resolve();
503
+ const executeNestedWriteInSavepoint = (asUser, targetType, payload) => {
504
+ const run = nestedWriteChain.then(() => runNestedWriteInSavepoint(asUser, targetType, payload));
505
+ nestedWriteChain = run.catch(() => undefined);
506
+ return run;
507
+ };
491
508
  // Without an open transaction (post-commit hooks, stream/query handlers, perCommand handlers) a bare
492
509
  // executeWrite would auto-commit event append, projections and inTransaction hooks separately and its
493
510
  // afterCommit hooks would land in a sink nobody flushes. A batch-of-one gives the write its own
@@ -498,7 +515,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
498
515
  const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
499
516
  const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
500
517
  // Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
501
- queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
518
+ queryAs: (asUser, targetType, payload) => executeQuery(ctx, targetType, payload, asUser, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
502
519
  writeAs: writeAsUser,
503
520
  }, identitySwitchAudit);
504
521
  // Lazy — creates the reader (which fails closed on SYSTEM_TENANT_ID) only
@@ -542,7 +559,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
542
559
  return projTable === undefined || hasTenantColumn(projTable);
543
560
  });
544
561
  const bridge = {
545
- query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
562
+ query: (targetType, payload) => executeQuery(ctx, targetType, payload, user, origin, tx, hasLiveCommitSink ? bridgeSink : outerNotifySink), // @wrapper-known semantic-alias
546
563
  queryAs: identitySwitch.queryAs,
547
564
  write: (targetType, payload) => writeAsUser(user, targetType, payload),
548
565
  writeAs: identitySwitch.writeAs,
@@ -597,16 +614,9 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
597
614
  message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
598
615
  });
599
616
  }
600
- // Stream-version authoritative (same policy as CRUD executor + Block 0).
601
- // A single SELECT MAX(version) is cheaper than loading the full stream
602
- // when the caller just wants to append — but most callers also want
603
- // the events (business-rule checks), so fetch both in parallel.
604
- const [storedEvents, fetchedVersion] = await Promise.all([
605
- loadAggregate(dbSource, args.aggregateId, user.tenantId, {
606
- aggregateType: args.aggregateType,
607
- }),
608
- getStreamVersion(dbSource, args.aggregateId, user.tenantId),
609
- ]);
617
+ // Stream-version authoritative (same policy as CRUD executor + Block 0), read together
618
+ // with the events so the pinned version covers exactly what the handler's rules saw.
619
+ const { events: storedEvents, streamVersion: fetchedVersion } = await loadAggregateForWriting(dbSource, args.aggregateId, user.tenantId, { aggregateType: args.aggregateType });
610
620
  const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
611
621
  db: dbSource,
612
622
  tenantId: user.tenantId,
@@ -831,11 +841,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
831
841
  // able to compute bucket keys.
832
842
  const { _rateLimitPayloadDigest: payloadDigest,
833
843
  // Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
834
- _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
844
+ _notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, _notifyAfterCommitFactory: _omitOuterNotifyAfterCommitFactory, ...contextForHandler } = context;
835
845
  const handlerContext = {
836
846
  ...contextForHandler,
837
847
  ...(payloadDigest && { rateLimitDigest: createHandlerBoundDigest(payloadDigest, type) }),
838
848
  ...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
849
+ ...(notifyAfterCommitFactory && { _notifyAfterCommitFactory: notifyAfterCommitFactory }),
839
850
  registry,
840
851
  db: exposedDb,
841
852
  dbOutsideTransaction,
@@ -2,7 +2,7 @@ import { accessInvalidationCredentialFor } from "../api/sse-broker.js";
2
2
  import { scheduleTokenExpiry } from "../api/token-expiry-timer.js";
3
3
  import { hasAccess } from "../engine/access.js";
4
4
  import { tenantOverrideDenied } from "../engine/cross-tenant.js";
5
- import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, validationErrorFromZod, } from "../errors/index.js";
5
+ import { AccessDeniedError, memberResolutionReadOnlyDenied, NotFoundError, UnauthenticatedError, validationErrorFromZod, } from "../errors/index.js";
6
6
  import { assertNoSecretLeak } from "../secrets/index.js";
7
7
  import { buildHandlerContext, enforceRateLimit, ensureFeatureEnabled, isMemberResolutionPrincipal, runStreamInstrumented, } from "./dispatch-shared.js";
8
8
  import { handlerAccessError } from "./handler-access-error.js";
@@ -69,10 +69,16 @@ async function* executeStreamInner(ctx, type, payload, user, origin, options) {
69
69
  abandonedForInvalidation = true;
70
70
  void nextPull.catch(() => { });
71
71
  void iterator.return?.(undefined)?.then(undefined, () => { });
72
+ // An expired token is a lapsed session (401, so clients show the
73
+ // session-end hint); revocation is a permission change (403).
74
+ if (outcome.reason === "expired") {
75
+ throw new UnauthenticatedError({
76
+ message: `token expired mid-stream for ${type}`,
77
+ details: { handler: type, reason: outcome.reason },
78
+ });
79
+ }
72
80
  throw new AccessDeniedError({
73
- message: outcome.reason === "expired"
74
- ? `token expired mid-stream for ${type}`
75
- : `access revoked mid-stream for ${type}`,
81
+ message: `access revoked mid-stream for ${type}`,
76
82
  details: { handler: type },
77
83
  });
78
84
  }
@@ -1,6 +1,7 @@
1
1
  import { runWithWriteOrigin } from "../api/request-context.js";
2
2
  import { TENANT_MEMBERSHIPS_QUERY } from "../engine/extension-names.js";
3
3
  import { InternalError, reraiseAsKumikoError } from "../errors/index.js";
4
+ import { withEscapeHatchAuditScopeStream } from "../observability/escape-hatch-report.js";
4
5
  import { createEscapeHatchReportWindow, getFallbackMeter, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
5
6
  import { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage } from "../redis/cache-sync-topics.js";
6
7
  import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership.js";
@@ -114,7 +115,8 @@ export function createDispatcher(registry, context, options = {}) {
114
115
  },
115
116
  stream: (typeOrRef, payload, user, options) => {
116
117
  const type = resolveType(typeOrRef);
117
- return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user), options);
118
+ const origin = rootWriteOrigin(registry, type, user);
119
+ return withEscapeHatchAuditScopeStream(() => executeStream(ctx, type, payload, user, origin, options));
118
120
  },
119
121
  async command(typeOrRef, payload, user) {
120
122
  const type = resolveType(typeOrRef);
@@ -1,4 +1,4 @@
1
- import type { PgClient } from "../db/connection.js";
1
+ import type { PgListenClient } from "../db/connection.js";
2
2
  import type { AppContext } from "../engine/types/index.js";
3
3
  import { type StoredEvent } from "../event-store/index.js";
4
4
  import { type Meter, type Tracer } from "../observability/index.js";
@@ -46,7 +46,7 @@ export type EventDispatcherOptions = {
46
46
  readonly tracer?: Tracer;
47
47
  readonly meter?: Meter;
48
48
  readonly instanceId?: string;
49
- readonly pgClient?: PgClient;
49
+ readonly pgClient?: PgListenClient;
50
50
  };
51
51
  export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
52
52
  export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.js";
@@ -114,7 +114,7 @@ export function createEventDispatcher(options) {
114
114
  // to EVENTS_PUBSUB_CHANNEL; cleared by .stop(). The timer remains active
115
115
  // even with LISTEN attached — it's a cheap safety net against missed
116
116
  // NOTIFYs (subscription drop, crash mid-commit).
117
- let pgUnlisten = null;
117
+ let pgSubscription = null;
118
118
  // Bounds how many consumer turns hold a DB transaction at once. Each turn's
119
119
  // db.begin() checks out one pool connection, plus any writes the handler
120
120
  // itself makes via context.db — the app db pool defaults to postgres-js's
@@ -498,15 +498,16 @@ export function createEventDispatcher(options) {
498
498
  // succeeds (onlisten callback). It drops to 0 on the initial connect
499
499
  // failure, on stop() and when the idle pre-check detects a DB outage.
500
500
  // An outage shorter than one poll tick is not detected; the gauge then
501
- // stays at 1 until postgres.js's delayed re-LISTEN lands.
501
+ // stays at 1 until the driver's delayed re-LISTEN lands.
502
502
  emitEventDispatcherListenConnected(meter, false);
503
503
  if (options.pgClient) {
504
504
  try {
505
505
  const sub = await options.pgClient.listen(EVENTS_PUBSUB_CHANNEL, runBackgroundPass, () => {
506
- // Fires on initial connect and again when postgres.js's own
506
+ // Fires on initial connect and again when the driver's own
507
507
  // re-LISTEN lands after a drop; during an outage that re-LISTEN
508
- // waits in its connection backoff (measured 10-30s after the DB
509
- // returns), polling covers delivery meanwhile. Never unlisten()
508
+ // waits in its connection backoff (postgres-js: measured 10-30s
509
+ // after the DB returns, Bun.SQL: up to 32s), polling covers
510
+ // delivery meanwhile. Never unlisten()
510
511
  // + listen() by hand: that leaves duplicate listeners.
511
512
  // ponytail: no own LISTEN reconnect, the backoff gap is polled;
512
513
  // add a dedicated LISTEN connection if prod wires a pgClient
@@ -514,7 +515,7 @@ export function createEventDispatcher(options) {
514
515
  listenSubscribed = true;
515
516
  emitEventDispatcherListenConnected(meter, true);
516
517
  });
517
- pgUnlisten = sub.unlisten;
518
+ pgSubscription = sub;
518
519
  }
519
520
  catch (e) {
520
521
  emitEventDispatcherListenConnected(meter, false);
@@ -532,12 +533,13 @@ export function createEventDispatcher(options) {
532
533
  clearInterval(timer);
533
534
  timer = null;
534
535
  }
535
- if (pgUnlisten) {
536
- await pgUnlisten().catch(() => {
536
+ if (pgSubscription) {
537
+ // Called on the subscription: Bun.SQL's unlisten is a prototype method.
538
+ await pgSubscription.unlisten().catch(() => {
537
539
  // skip: unlisten failure only matters during shutdown — the
538
540
  // subscription is being torn down anyway.
539
541
  });
540
- pgUnlisten = null;
542
+ pgSubscription = null;
541
543
  listenSubscribed = false;
542
544
  emitEventDispatcherListenConnected(meter, false);
543
545
  }
@@ -6,6 +6,7 @@ export type PruneEventsOptions = {
6
6
  readonly aggregateTypes: readonly string[];
7
7
  readonly aggregateIds?: readonly string[];
8
8
  readonly dryRun?: boolean;
9
+ readonly batchSize?: number;
9
10
  };
10
11
  export type PruneEventsResult = {
11
12
  readonly deletedCount: number;
@@ -34,6 +34,10 @@ export async function pruneEvents(db, options) {
34
34
  if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
35
35
  throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
36
36
  }
37
+ if (options.batchSize !== undefined &&
38
+ (!Number.isInteger(options.batchSize) || options.batchSize < 1)) {
39
+ throw new Error("pruneEvents: batchSize must be a positive integer.");
40
+ }
37
41
  const aggregateTypes = options.aggregateTypes;
38
42
  const aggregateIds = options.aggregateIds;
39
43
  const dryRun = options.dryRun === true;
@@ -56,7 +60,9 @@ export async function pruneEvents(db, options) {
56
60
  aggregateType: [...aggregateTypes],
57
61
  ...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
58
62
  createdAt: { lt: cutoff },
59
- });
63
+ }, options.batchSize === undefined
64
+ ? undefined
65
+ : { orderBy: { col: "id", direction: "asc" }, limit: options.batchSize });
60
66
  if (candidates.length === 0) {
61
67
  return { deletedCount: 0, cutoff, aggregateTypes, dryRun };
62
68
  }
@@ -31,6 +31,8 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
31
31
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
32
32
  export type { MspRebuildDeps } from "./msp-rebuild.js";
33
33
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
34
+ export type { MultiStreamApplyContext, MultiStreamApplyContextDeps, } from "./multi-stream-apply-context.js";
35
+ export { createMultiStreamApplyContext } from "./multi-stream-apply-context.js";
34
36
  export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
35
37
  export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
36
38
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
@@ -16,6 +16,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
16
16
  export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
17
17
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
18
18
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
19
+ export { createMultiStreamApplyContext } from "./multi-stream-apply-context.js";
19
20
  export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
20
21
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
21
22
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
@@ -14,5 +14,6 @@ export type MultiStreamApplyContextDeps = {
14
14
  readonly files?: FileContext;
15
15
  readonly derivatives?: DerivativesContext;
16
16
  readonly secrets?: SecretsContext;
17
+ readonly jobs?: MultiStreamApplyContext["jobs"];
17
18
  };
18
19
  export declare function createMultiStreamApplyContext(deps: MultiStreamApplyContextDeps): MultiStreamApplyContext;
@@ -7,6 +7,7 @@ export function createMultiStreamApplyContext(deps) {
7
7
  ...(deps.files ? { files: deps.files } : {}),
8
8
  ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
9
9
  ...(deps.secrets ? { secrets: deps.secrets } : {}),
10
+ ...(deps.jobs ? { jobs: deps.jobs } : {}),
10
11
  appendEvent: (async (args) => {
11
12
  await appendDomainEventCore({
12
13
  registry: deps.registry,
@@ -23,8 +23,10 @@ export function createDeferringNotify(deps) {
23
23
  assertNotificationVariables(registry, notificationType, options.data);
24
24
  if (options.immediate === true)
25
25
  return notifyNow(notificationType, options);
26
+ // The hook runs after the handler returned; a caller reusing `data` meanwhile must not change what is sent.
27
+ const optionsAtCallTime = structuredClone(options);
26
28
  scheduleAfterCommit(async () => {
27
- await notifyAfterCommit(notificationType, options);
29
+ await notifyAfterCommit(notificationType, optionsAtCallTime);
28
30
  });
29
31
  return { kind: "deferred" };
30
32
  }
@@ -73,7 +73,8 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
73
73
  }
74
74
  let prepared;
75
75
  if (handler.beforeTransaction) {
76
- const phase = await handler.beforeTransaction(event, applyBeforeTransactionReadOnly(handlerContext));
76
+ const phaseContext = await buildHandlerContext(ctx, command.type, user, origin, undefined, undefined, undefined, undefined, undefined, true);
77
+ const phase = await handler.beforeTransaction(event, applyBeforeTransactionReadOnly(phaseContext));
77
78
  if (!phase.isSuccess) {
78
79
  await releaseAll(ctx, releases);
79
80
  return { isSuccess: false, error: phase.error, failedIndex: index };
@@ -439,8 +439,19 @@ export async function runSchemaCli(argv, appCwd, out, options = {}) {
439
439
  await reportLeftoverDroppedTables(db, migrationsDir, out);
440
440
  return pending === 0 ? 0 : 1;
441
441
  }
442
+ catch (e) {
443
+ // Message only, never dbUrl: it can carry the password.
444
+ out.err(` ✗ schema status failed: ${e instanceof Error ? e.message : String(e)}`);
445
+ out.err(' Check that DATABASE_URL is reachable and the database exists (the scaffold\'s docker-compose creates "app").');
446
+ return 1;
447
+ }
442
448
  finally {
443
- await close();
449
+ try {
450
+ await close();
451
+ }
452
+ catch {
453
+ // The connection already failed and was reported above; a failing close must not throw again.
454
+ }
444
455
  }
445
456
  }
446
457
  default: {
@@ -1,9 +1,9 @@
1
- import type { DbConnection } from "@cosmicdrift/kumiko-types/db-connection";
1
+ import type { DbConnection, PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
2
2
  declare function requireEnv(name: string): string;
3
3
  export type TestDb = {
4
4
  db: DbConnection;
5
5
  outsideTransactionDb: DbConnection;
6
- client: unknown;
6
+ client: PgListenClient;
7
7
  dbName: string;
8
8
  cleanup: () => Promise<void>;
9
9
  };
@@ -275,7 +275,8 @@ export async function setupTestStack(options) {
275
275
  (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
276
276
  const rateLimitFallback = needsOwnRateLimitResolver
277
277
  ? createRateLimitResolver({
278
- redis: testRedis.redis,
278
+ // An app-supplied redis keeps buckets and app redis on the same instance.
279
+ redis: appContext.redis ?? testRedis.redis,
279
280
  ...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
280
281
  })
281
282
  : undefined;
@@ -22,3 +22,4 @@ export { sharedItemEntity, sharedItemTable, sharedUserEntity, sharedUserTable, s
22
22
  export { sleep } from "./utils.js";
23
23
  export { waitFor } from "./wait-for.js";
24
24
  export { withoutAmbientTemporal } from "./without-ambient-temporal.js";
25
+ export { zipWithEntries } from "./zip-fixture.js";
@@ -30,3 +30,4 @@ export { sharedItemEntity, sharedItemTable, sharedUserEntity, sharedUserTable, s
30
30
  export { sleep } from "./utils.js";
31
31
  export { waitFor } from "./wait-for.js";
32
32
  export { withoutAmbientTemporal } from "./without-ambient-temporal.js";
33
+ export { zipWithEntries } from "./zip-fixture.js";
@@ -1,3 +1,9 @@
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
+ */
1
7
  export declare function buildMultipartBody(fd: FormData): Promise<{
2
8
  body: BodyInit;
3
9
  contentType: string;
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  // Workarounds for Bun v1.3.x bun:test limitations with multipart/form-data.
2
3
  //
3
4
  // Two bugs combine to break file upload tests in bun:test:
@@ -19,7 +20,6 @@
19
20
  * Returns the encoded body and the Content-Type header value (including the
20
21
  * generated boundary). Pass both directly to app.request or fetch.
21
22
  */
22
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
23
23
  export async function buildMultipartBody(fd) {
24
24
  const boundary = `KumikoBnd${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
25
25
  const enc = new TextEncoder();
@@ -0,0 +1 @@
1
+ export declare function zipWithEntries(...entryNames: readonly string[]): Uint8Array;
@@ -0,0 +1,33 @@
1
+ const LOCAL_HEADER_FIXED_LENGTH = 30;
2
+ const CENTRAL_HEADER_FIXED_LENGTH = 46;
3
+ const EOCD_LENGTH = 22;
4
+ // Minimal stored ZIP (no compression, zero CRC): enough structure for readers that
5
+ // walk the central directory. Not for extraction.
6
+ export function zipWithEntries(...entryNames) {
7
+ const encoder = new TextEncoder();
8
+ const names = entryNames.map((name) => encoder.encode(name));
9
+ const localSize = names.reduce((sum, name) => sum + LOCAL_HEADER_FIXED_LENGTH + name.length, 0);
10
+ const centralSize = names.reduce((sum, name) => sum + CENTRAL_HEADER_FIXED_LENGTH + name.length, 0);
11
+ const bytes = new Uint8Array(localSize + centralSize + EOCD_LENGTH);
12
+ const view = new DataView(bytes.buffer);
13
+ let localOffset = 0;
14
+ let centralOffset = localSize;
15
+ for (const name of names) {
16
+ view.setUint32(localOffset, 0x04034b50, true);
17
+ view.setUint16(localOffset + 26, name.length, true);
18
+ bytes.set(name, localOffset + LOCAL_HEADER_FIXED_LENGTH);
19
+ view.setUint32(centralOffset, 0x02014b50, true);
20
+ view.setUint16(centralOffset + 28, name.length, true);
21
+ view.setUint32(centralOffset + 42, localOffset, true);
22
+ bytes.set(name, centralOffset + CENTRAL_HEADER_FIXED_LENGTH);
23
+ localOffset += LOCAL_HEADER_FIXED_LENGTH + name.length;
24
+ centralOffset += CENTRAL_HEADER_FIXED_LENGTH + name.length;
25
+ }
26
+ const eocdOffset = localSize + centralSize;
27
+ view.setUint32(eocdOffset, 0x06054b50, true);
28
+ view.setUint16(eocdOffset + 8, names.length, true);
29
+ view.setUint16(eocdOffset + 10, names.length, true);
30
+ view.setUint32(eocdOffset + 12, centralSize, true);
31
+ view.setUint32(eocdOffset + 16, localSize, true);
32
+ return bytes;
33
+ }
@@ -18,8 +18,10 @@ export function cardFileDisplayKind(type, renderer) {
18
18
  // boot validator so the validator counts exactly what the card would draw.
19
19
  // The first image column is the card's leading preview, neither title nor meta.
20
20
  export function cardColumnRoles(columns) {
21
- const image = columns.find((col) => col.fileDisplay?.kind === "image");
22
- const textColumns = columns.filter((col) => col !== image);
21
+ const isImage = (col) => col.fileDisplay?.kind === "image";
22
+ const image = columns.find((col) => isImage(col) && col.hideOnNarrow !== true);
23
+ // A narrow-hidden image must not turn into the title just because it is not the preview.
24
+ const textColumns = columns.filter((col) => col !== image && !(isImage(col) && col.hideOnNarrow === true));
23
25
  const title = textColumns.find((col) => col.highlighted === true) ?? textColumns[0];
24
26
  const status = textColumns.find((col) => col !== title && col.type === "select" && col.renderer === undefined);
25
27
  const meta = textColumns.filter((col) => col !== title && col !== status && col.hideOnNarrow !== true);
@@ -4,7 +4,7 @@ export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedd
4
4
  export type { ParsedRefTarget } from "../engine/parse-ref-target.js";
5
5
  export { parseRefTarget } from "../engine/parse-ref-target.js";
6
6
  export type { FieldsOrGroupsSection, UnavailableSelectOption } from "../engine/screen-helpers.js";
7
- export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
7
+ export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, 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";
@@ -22,7 +22,7 @@
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 { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
25
+ export { availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, 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
28
  export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";