@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
@@ -11,6 +11,18 @@ export function checkQueryRef(queryHandlers, query, buildPrefix) {
11
11
  return;
12
12
  throw new Error(`${buildPrefix()} query "${query}" ${NOT_REGISTERED_SUFFIX}`);
13
13
  }
14
+ // visibleWhen queries are run with an empty payload by the client gate, so a query
15
+ // that requires input would fail on every call and the screen/panel would stay
16
+ // unavailable with no hint.
17
+ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
18
+ checkQueryRef(queryHandlers, query, buildPrefix);
19
+ const handler = queryHandlers.get(query);
20
+ // skip: nothing to check for an unknown handler (checkQueryRef already threw) or an input-free query
21
+ if (handler === undefined || handler.schema.safeParse({}).success)
22
+ return;
23
+ throw new Error(`${buildPrefix()} query "${query}" requires input; visibleWhen queries run with an empty payload. ` +
24
+ "Make the query's input fields optional or use a query without required input.");
25
+ }
14
26
  // relatedList is currently only reachable on projectionDetail — screens.ts
15
27
  // rejects it outright on entityEdit/actionForm/configEdit (fw#2166). Walking
16
28
  // the shared EditLayout uniformly here (instead of excluding those three
@@ -27,7 +39,7 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
27
39
  if (panel.kind === "custom")
28
40
  continue;
29
41
  if (panel.visibleWhen !== undefined) {
30
- checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
42
+ checkVisibleWhenQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
31
43
  }
32
44
  if (panel.kind === "screen")
33
45
  continue;
@@ -60,7 +72,7 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
60
72
  }
61
73
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
62
74
  if (screen.visibleWhen !== undefined) {
63
- checkQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
75
+ checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
64
76
  }
65
77
  if (screen.type === "projectionList") {
66
78
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
@@ -11,13 +11,40 @@ function sameKeys(a, b) {
11
11
  const bKeys = new Set(Object.keys(b ?? {}));
12
12
  return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
13
13
  }
14
+ const KEYED_SURFACE_PROPERTIES = [
15
+ "entities",
16
+ "writeHandlers",
17
+ "queryHandlers",
18
+ "streamHandlers",
19
+ "screens",
20
+ "navs",
21
+ "jobs",
22
+ "events",
23
+ "translations",
24
+ "configKeys",
25
+ "notifications",
26
+ "metrics",
27
+ ];
28
+ // Hooks are keyed per phase, then per target: flatten so a hook added under an
29
+ // existing phase still shows up as a difference.
30
+ function hookKeys(feature) {
31
+ const keys = new Set();
32
+ for (const [phase, byTarget] of Object.entries(feature.hooks ?? {})) {
33
+ for (const target of Object.keys(byTarget ?? {}))
34
+ keys.add(`${phase}/${target}`);
35
+ }
36
+ return keys;
37
+ }
38
+ function sameHookKeys(a, b) {
39
+ const aKeys = hookKeys(a);
40
+ const bKeys = hookKeys(b);
41
+ return aKeys.size === bKeys.size && [...aKeys].every((key) => bKeys.has(key));
42
+ }
14
43
  // dedupeOptions is a declaration, not proof: two same-named features that both
15
44
  // carry `{}` but register different surface must still clash, not silently drop one.
16
45
  function sameRegisteredSurface(a, b) {
17
- return (sameKeys(a.entities, b.entities) &&
18
- sameKeys(a.writeHandlers, b.writeHandlers) &&
19
- sameKeys(a.queryHandlers, b.queryHandlers) &&
20
- sameKeys(a.streamHandlers, b.streamHandlers));
46
+ return (KEYED_SURFACE_PROPERTIES.every((property) => sameKeys(a[property], b[property])) &&
47
+ sameHookKeys(a, b));
21
48
  }
22
49
  function isInterchangeable(existing, next) {
23
50
  if (existing === next)
@@ -4,6 +4,7 @@ import { collectReferenceFields, enrichRowWithReferences, enrichWithReferences,
4
4
  import { createEventStoreExecutor } from "../db/event-store-executor.js";
5
5
  import { buildEntityTable } from "../db/table-builder.js";
6
6
  import { acknowledgeConventionCrossTenant, reportConventionCrossTenantUse, } from "../db/tenant-db.js";
7
+ import { InternalError } from "../errors/index.js";
7
8
  import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
8
9
  import { assertUnreachable } from "../utils/index.js";
9
10
  import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
@@ -49,8 +50,8 @@ const WRITE_VERBS = ["create", "update", "delete", "restore"];
49
50
  const QUERY_VERBS = ["list", "detail"];
50
51
  const idSchema = z.object({ id: z.uuid() });
51
52
  const SYSTEM_ADMIN_ROLE = "SystemAdmin";
52
- // A write handler with escapeHatch reads/writes rows of every tenant, so only an operator
53
- // role may reach it — otherwise any tenant-level role holder could mutate foreign tenants.
53
+ // A handler with escapeHatch reads/writes rows of every tenant, so only an operator
54
+ // role may reach it — otherwise any tenant-level role holder could read or mutate foreign tenants.
54
55
  function isSystemAdminOnly(access) {
55
56
  return ("roles" in access &&
56
57
  access.roles.length > 0 &&
@@ -65,8 +66,8 @@ function resolveCrossTenantReason(name, options, kind) {
65
66
  const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
66
67
  if (placeholderProblem !== undefined)
67
68
  throw new Error(`"${name}": ${placeholderProblem}`);
68
- if (kind === "write" && !isSystemAdminOnly(options.access)) {
69
- throw new Error(`"${name}": a write handler with escapeHatch reaches across every tenant, so its access must be SystemAdmin-only ({ roles: ["${SYSTEM_ADMIN_ROLE}"] }); got ${JSON.stringify(options.access)}.`);
69
+ if (!isSystemAdminOnly(options.access)) {
70
+ throw new Error(`"${name}": a ${kind} handler with escapeHatch reaches across every tenant, so its access must be SystemAdmin-only ({ roles: ["${SYSTEM_ADMIN_ROLE}"] }); got ${JSON.stringify(options.access)}.`);
70
71
  }
71
72
  return options.escapeHatch.reason;
72
73
  }
@@ -235,7 +236,13 @@ export function defineEntityWriteHandler(name, entity, options) {
235
236
  }
236
237
  if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
237
238
  return undefined;
238
- return parseTenantId(rowTenantId) ?? undefined;
239
+ const streamTenantId = parseTenantId(rowTenantId);
240
+ if (streamTenantId === null) {
241
+ throw new InternalError({
242
+ message: `"${name}": row ${String(id)} has tenantId "${rowTenantId}" which is not a TenantId; refusing to append to the wrong stream.`,
243
+ });
244
+ }
245
+ return streamTenantId;
239
246
  };
240
247
  let schema;
241
248
  let handler;
@@ -569,7 +576,7 @@ export function registerEntityCrud(r, entityName, entity, options) {
569
576
  ...writeOpts,
570
577
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
571
578
  description: options?.descriptions?.[verb] ?? writeOpts?.description,
572
- ...(verbAgent !== undefined && { agent: verbAgent }),
579
+ ...(verbAgent !== undefined && { agent: { ...writeOpts?.agent, ...verbAgent } }),
573
580
  ...(excludeFields !== undefined && { excludeFields }),
574
581
  };
575
582
  };
@@ -579,7 +586,7 @@ export function registerEntityCrud(r, entityName, entity, options) {
579
586
  ...readOpts,
580
587
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
581
588
  description: options?.descriptions?.[verb] ?? readOpts?.description,
582
- ...(verbAgent !== undefined && { agent: verbAgent }),
589
+ ...(verbAgent !== undefined && { agent: { ...readOpts?.agent, ...verbAgent } }),
583
590
  };
584
591
  };
585
592
  if (verbs.create) {
@@ -78,10 +78,10 @@ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-ste
78
78
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
79
79
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
80
80
  export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
81
- export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
81
+ export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
82
82
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, type TransferAdjacency, type TransferEdge, type TransferEdgeLink, } from "./transfer-adjacency.js";
83
83
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
84
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
84
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextParam, DashboardValueFormat, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EmbeddedListEditSpec, EntityDefinition, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MaxPerTenantOptions, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreparedWriteEvent, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TenantId, TenantWriteTarget, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
85
85
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
86
86
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
87
87
  export { runValidation } from "./validation.js";
@@ -71,7 +71,7 @@ export { unwrapWorkflowStepError, WorkflowStepError } from "./steps/workflow-ste
71
71
  export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
72
72
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
73
73
  export { assertNotificationVariables, templateVariable, templateVariablesToZod, } from "./template-variables.js";
74
- export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
74
+ export { findTierResolverUsage, isTierResolverPlugin, requireTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
75
75
  export { findOverDeepTransferChain, resolveTransferAdjacency as resolveEntityTransferAdjacency, } from "./transfer-adjacency.js";
76
76
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
77
77
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
@@ -26,6 +26,7 @@ export declare function resolveJobTriggers(state: RegistryState): void;
26
26
  export declare function validateLiveEntities(state: RegistryState): void;
27
27
  export declare function validateBootGates(state: RegistryState): void;
28
28
  export declare function validateJobBackoff(state: RegistryState): void;
29
+ export declare function validateMaxPerTenant(state: RegistryState): void;
29
30
  export declare function validateExtensionUsageTargets(state: RegistryState): void;
30
31
  export declare function computeHasRateLimitedHandler(state: RegistryState): void;
31
32
  export declare function publishEventPiiCatalog(state: RegistryState): void;
@@ -1,6 +1,7 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii.js";
2
2
  import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption.js";
3
3
  import { entityEventName } from "../db/event-store-executor-context.js";
4
+ import { resolveMaxPerTenant } from "../jobs/max-per-tenant.js";
4
5
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
5
6
  import { resolveName } from "./handler-helpers.js";
6
7
  import { parseRefTargetEntityName } from "./parse-ref-target.js";
@@ -744,6 +745,26 @@ export function validateJobBackoff(state) {
744
745
  }
745
746
  }
746
747
  }
748
+ export function validateMaxPerTenant(state) {
749
+ for (const [jobName, jobDef] of state.jobMap) {
750
+ const resolved = resolveMaxPerTenant(jobDef);
751
+ if (!resolved)
752
+ continue;
753
+ if (!Number.isInteger(resolved.limit) || resolved.limit <= 0) {
754
+ throw new Error(`Job "${jobName}" maxPerTenant limit must be a positive integer (got ${resolved.limit})`);
755
+ }
756
+ if (typeof jobDef.maxPerTenant !== "object")
757
+ continue;
758
+ const { deferMs } = jobDef.maxPerTenant;
759
+ if (deferMs !== undefined && (!Number.isInteger(deferMs) || deferMs <= 0)) {
760
+ throw new Error(`Job "${jobName}" maxPerTenant.deferMs must be a positive integer (got ${deferMs})`);
761
+ }
762
+ // The inline boot-gate path has no BullMQ token to move the job to delayed with.
763
+ if (resolved.onLimit === "defer" && jobDef.bootGate) {
764
+ throw new Error(`Job "${jobName}" cannot combine bootGate with maxPerTenant onLimit "defer"`);
765
+ }
766
+ }
767
+ }
747
768
  export function validateExtensionUsageTargets(state) {
748
769
  // Validate: extension usages must reference existing extensions
749
770
  for (const usage of state.extensionUsages) {
@@ -3,7 +3,7 @@ import { dedupeFeatures } from "./dedupe-features.js";
3
3
  import { buildRegistryFacade } from "./registry-facade.js";
4
4
  import { populateClaimsAndAuth, populateConfigKeys, populateEvents, populateExtensionsAndSeeds, populateFeatureCore, populateHandlers, populateHooks, populateJobsAndNotifications, populateMetricsAndSecrets, populateProjectionsAndTables, populateScreensNavWorkspaces, populateSystemEvents, populateTranslations, } from "./registry-ingest.js";
5
5
  import { createInitialState } from "./registry-state.js";
6
- import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateNotificationRecordMappings, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
6
+ import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateMaxPerTenant, validateNotificationRecordMappings, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
7
  // This is where the magic happens. By "magic" I mean: precomputed maps.
8
8
  // I build everything once at boot (hooks, relations, searchable fields, ...)
9
9
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
@@ -48,6 +48,7 @@ export function createRegistry(rawFeatures) {
48
48
  validateBootGates(state);
49
49
  validateLiveEntities(state);
50
50
  validateJobBackoff(state);
51
+ validateMaxPerTenant(state);
51
52
  validateExtensionUsageTargets(state);
52
53
  computeHasRateLimitedHandler(state);
53
54
  publishEventPiiCatalog(state);
@@ -34,6 +34,7 @@ export type UnavailableSelectOption = {
34
34
  };
35
35
  /** Options of a select field that are offered for the given row values, in declaration order. */
36
36
  export declare function availableSelectOptions(field: SelectOptionRules, values: Record<string, unknown>): readonly string[];
37
+ export declare function isUnchangedValue(a: unknown, b: unknown): boolean;
37
38
  /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
38
39
  * checked when it or one of its condition fields changed, so legacy rows stay editable. */
39
40
  export declare function findUnavailableSelectOptions(fields: Readonly<Record<string, FieldDefinition>>, values: Record<string, unknown>, previous?: Record<string, unknown>): readonly UnavailableSelectOption[];
@@ -126,7 +126,7 @@ export function availableSelectOptions(field, values) {
126
126
  return field.options;
127
127
  return field.options.filter((option) => rules.every((rule) => !rule.options.includes(option) || evalFieldCondition(rule.when, values)));
128
128
  }
129
- function isSameValue(a, b) {
129
+ export function isUnchangedValue(a, b) {
130
130
  return JSON.stringify(a) === JSON.stringify(b);
131
131
  }
132
132
  /** Select values that violate a `conditionalOptions` rule. With `previous`, a field is only
@@ -141,7 +141,7 @@ export function findUnavailableSelectOptions(fields, values, previous) {
141
141
  continue;
142
142
  if (previous) {
143
143
  const watched = [name, ...def.conditionalOptions.map((rule) => rule.when.field)];
144
- if (watched.every((key) => isSameValue(values[key], previous[key])))
144
+ if (watched.every((key) => isUnchangedValue(values[key], previous[key])))
145
145
  continue;
146
146
  }
147
147
  const allowed = availableSelectOptions(def, values);
@@ -1,4 +1,4 @@
1
- import { unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
1
+ import { requestedTenantIds, unsafeRawForDeclaredStep } from "../../db/tenant-db.js";
2
2
  import { InternalError } from "../../errors/classes.js";
3
3
  import { SYSTEM_TENANT_ID } from "../types/identifiers.js";
4
4
  // unsafeRaw fails closed without the handler's escapeHatch (or systemScope) and reports the use.
@@ -25,7 +25,10 @@ export function warnOnNarrowedForeignTenantFilter(ctx, stepKind, stepName, where
25
25
  if (requested === undefined)
26
26
  return;
27
27
  const allowed = [ctx.db.tenantId, SYSTEM_TENANT_ID];
28
- const requestedList = Array.isArray(requested) ? requested : [requested];
28
+ const requestedList = requestedTenantIds(requested);
29
+ // skip: unrecognised operator form, narrowing cannot be judged.
30
+ if (requestedList === undefined)
31
+ return;
29
32
  // skip: every requested tenant is within the caller's scope.
30
33
  if (requestedList.every((t) => typeof t === "string" && allowed.includes(t)))
31
34
  return;
@@ -61,4 +61,5 @@ export type TierResolverPlugin = {
61
61
  * plugin shape — the only thing every implementation must provide is the
62
62
  * `build` factory. */
63
63
  export declare function isTierResolverPlugin(v: unknown): v is TierResolverPlugin;
64
+ export declare function requireTierResolverPlugin(usage: RegistrarExtensionRegistration): TierResolverPlugin;
64
65
  export declare function findTierResolverUsage(features: readonly FeatureDefinition[]): RegistrarExtensionRegistration | undefined;
@@ -42,6 +42,12 @@ export const TENANT_TIER_RESOLVER_EXT = "tenantTierResolver";
42
42
  export function isTierResolverPlugin(v) {
43
43
  return isPlainObject(v) && typeof v["build"] === "function";
44
44
  }
45
+ export function requireTierResolverPlugin(usage) {
46
+ if (!isTierResolverPlugin(usage.options)) {
47
+ throw new Error("tier-resolver registration options must provide a build() factory");
48
+ }
49
+ return usage.options;
50
+ }
45
51
  export function findTierResolverUsage(features) {
46
52
  for (const feature of features) {
47
53
  for (const usage of feature.extensionUsages) {
@@ -1,5 +1,5 @@
1
1
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
2
- export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
2
+ export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
3
3
  export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
4
4
  export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
5
5
  export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
@@ -3,7 +3,7 @@ import type { AuthRoutesConfig } from "../api/auth-routes.js";
3
3
  import type { JwtHelper, JwtKeyring } from "../api/jwt.js";
4
4
  import type { ServerOptions } from "../api/server.js";
5
5
  import type { SseBroker } from "../api/sse-broker.js";
6
- import type { PgClient } from "../db/connection.js";
6
+ import type { PgClient, PgListenClient } from "../db/connection.js";
7
7
  import type { AppContext, Registry } from "../engine/types/index.js";
8
8
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.js";
9
9
  import type { Lifecycle } from "../lifecycle/index.js";
@@ -82,4 +82,4 @@ export type AllInOneEntrypoint = Omit<ApiEntrypoint, "mode"> & Omit<WorkerEntryp
82
82
  export declare function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint;
83
83
  export declare function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint;
84
84
  export declare function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint;
85
- export type { PgClient };
85
+ export type { PgClient, PgListenClient };
@@ -20,17 +20,17 @@ function isCoveredByConfig(reason, errorDocs) {
20
20
  function errorsUrl(baseUrl, slug) {
21
21
  return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
22
22
  }
23
- // A reason the framework documents (or the error code itself) links to the framework docs,
24
- // overridable via KUMIKO_DOCS_URL. An app's own reason links only where `errorDocs` covers it —
25
- // otherwise the framework docs would have no page for it.
23
+ // A reason the framework documents (or no reason, or the code itself) links to the framework docs, overridable via
24
+ // KUMIKO_DOCS_URL. An app's own reason links to the app docs where `errorDocs` covers it; every
25
+ // other reason falls back to the framework page of the error code, which exists for each code.
26
26
  export function resolveErrorDocsUrl(err, errorDocs) {
27
27
  const reason = reasonOf(err);
28
+ const frameworkBaseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
28
29
  if (reason === undefined || reason === err.code || frameworkReasons.has(reason)) {
29
- const baseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
30
- return errorsUrl(baseUrl, reason ?? err.code);
30
+ return errorsUrl(frameworkBaseUrl, reason ?? err.code);
31
31
  }
32
32
  if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
33
33
  return errorsUrl(errorDocs.baseUrl, reason);
34
34
  }
35
- return undefined;
35
+ return errorsUrl(frameworkBaseUrl, err.code);
36
36
  }
@@ -9,7 +9,7 @@ export type { AgentReason, FrameworkReason } from "./reasons.js";
9
9
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
10
10
  export type { ErrorLogEntry, ErrorResponseBody } from "./serialize.js";
11
11
  export { buildErrorLog, serializeError } from "./serialize.js";
12
- export { subjectErasedConflict } from "./subject-erased.js";
12
+ export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
13
13
  export { toKumikoError } from "./to-kumiko-error.js";
14
14
  export type { InvalidTransitionDetails } from "./transition-details.js";
15
15
  export { buildInvalidTransitionDetails } from "./transition-details.js";
@@ -4,7 +4,7 @@ export { isKumikoError, KumikoError } from "./kumiko-error.js";
4
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
5
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
6
6
  export { buildErrorLog, serializeError } from "./serialize.js";
7
- export { subjectErasedConflict } from "./subject-erased.js";
7
+ export { isSubjectErasedConflict, subjectErasedConflict } from "./subject-erased.js";
8
8
  export { toKumikoError } from "./to-kumiko-error.js";
9
9
  export { buildInvalidTransitionDetails } from "./transition-details.js";
10
10
  export { failNotFound, failTransition, failUnprocessable, reraiseAsKumikoError, toWriteErrorInfo, writeFailure, } from "./write-error-info.js";
@@ -1,2 +1,3 @@
1
1
  import { ConflictError } from "./classes.js";
2
2
  export declare function subjectErasedConflict(cause?: unknown): ConflictError;
3
+ export declare function isSubjectErasedConflict(e: unknown): e is ConflictError;
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  import { ConflictError } from "./classes.js";
2
3
  import { FrameworkReasons } from "./reasons.js";
3
4
  export function subjectErasedConflict(cause) {
@@ -7,3 +8,8 @@ export function subjectErasedConflict(cause) {
7
8
  ...(cause instanceof Error && { cause }),
8
9
  });
9
10
  }
11
+ export function isSubjectErasedConflict(e) {
12
+ return (e instanceof ConflictError &&
13
+ isPlainObject(e.details) &&
14
+ e.details["reason"] === FrameworkReasons.subjectErased);
15
+ }
@@ -7,6 +7,7 @@
7
7
  // Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
8
8
  // Allowlist: samples/*/migration/, scripts/migrations/, die Definition
9
9
  // selbst, das Guard-Script selbst.
10
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
10
11
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
11
12
  import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
12
13
  import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
@@ -53,7 +54,7 @@ function rawEventParams(event, newVersion, eventVersion) {
53
54
  eventVersion,
54
55
  payload: event.payload,
55
56
  metadata: event.metadata,
56
- createdAt: event.createdAt.toString(),
57
+ createdAt: instantToTimestamptzParam(event.createdAt),
57
58
  createdBy: event.createdBy,
58
59
  };
59
60
  }
@@ -88,7 +89,7 @@ export async function appendRawBatch(runner, events) {
88
89
  const newVersion = e.expectedVersion + 1;
89
90
  const eventVersion = e.eventVersion ?? 1;
90
91
  const baseIdx = params.length;
91
- params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, e.createdAt.toString(), e.createdBy);
92
+ params.push(e.aggregateId, e.aggregateType, e.tenantId, newVersion, e.type, eventVersion, e.payload, e.metadata, instantToTimestamptzParam(e.createdAt), e.createdBy);
92
93
  return `($${baseIdx + 1}::uuid, $${baseIdx + 2}, $${baseIdx + 3}::uuid, $${baseIdx + 4}, $${baseIdx + 5}, $${baseIdx + 6}, $${baseIdx + 7}::jsonb, $${baseIdx + 8}::jsonb, $${baseIdx + 9}::timestamptz, $${baseIdx + 10})`;
93
94
  });
94
95
  try {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  export declare class VersionConflictError extends Error {
2
3
  readonly aggregateId: string;
3
4
  readonly expectedVersion: number;
@@ -8,6 +9,14 @@ export declare class IdempotentAppendConflictError extends Error {
8
9
  readonly idempotencyKey: string;
9
10
  constructor(tenantId: string, idempotencyKey: string);
10
11
  }
12
+ export declare class SeedTimeOrderError extends Error {
13
+ readonly aggregateType: string;
14
+ readonly aggregateId: string;
15
+ readonly version: number;
16
+ readonly seedCreatedAt: Temporal.Instant;
17
+ readonly predecessorCreatedAt: Temporal.Instant;
18
+ constructor(aggregateType: string, aggregateId: string, version: number, seedCreatedAt: Temporal.Instant, predecessorCreatedAt: Temporal.Instant);
19
+ }
11
20
  export declare class ArchivedStreamError extends Error {
12
21
  readonly tenantId: string;
13
22
  readonly aggregateId: string;
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  // Failure modes of the event-store's append() path. Surfaced as typed
2
3
  // errors so the executor layer can map them to the framework's
3
4
  // WriteResult error contract (version_conflict).
@@ -31,6 +32,25 @@ export class IdempotentAppendConflictError extends Error {
31
32
  this.idempotencyKey = idempotencyKey;
32
33
  }
33
34
  }
35
+ // Thrown when a back-dated seed write would put a stream's next version
36
+ // before its predecessor in time. loadAggregateAsOf and projection rebuilds
37
+ // order by created_at, so such a stream would read and replay wrongly.
38
+ export class SeedTimeOrderError extends Error {
39
+ aggregateType;
40
+ aggregateId;
41
+ version;
42
+ seedCreatedAt;
43
+ predecessorCreatedAt;
44
+ constructor(aggregateType, aggregateId, version, seedCreatedAt, predecessorCreatedAt) {
45
+ super(`Seed write for ${aggregateType} ${aggregateId} v${version} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessorCreatedAt.toString()}). Write a stream's events in ascending time.`);
46
+ this.name = "SeedTimeOrderError";
47
+ this.aggregateType = aggregateType;
48
+ this.aggregateId = aggregateId;
49
+ this.version = version;
50
+ this.seedCreatedAt = seedCreatedAt;
51
+ this.predecessorCreatedAt = predecessorCreatedAt;
52
+ }
53
+ }
34
54
  // Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
35
55
  // are read-only — restoreStream() makes them writable again. The archive
36
56
  // state is not carried on the events themselves; it lives on the sparse
@@ -20,6 +20,12 @@ export declare function loadAggregate(db: DbRunner, aggregateId: string, tenantI
20
20
  readonly includeArchived?: boolean;
21
21
  readonly aggregateType?: string;
22
22
  }): Promise<readonly StoredEvent[]>;
23
+ export declare function loadAggregateForWriting(db: DbRunner, aggregateId: string, tenantId: TenantId, options: {
24
+ readonly aggregateType: string;
25
+ }): Promise<{
26
+ readonly events: readonly StoredEvent[];
27
+ readonly streamVersion: number;
28
+ }>;
23
29
  export declare function loadAggregateAsOf(db: DbRunner, aggregateId: string, tenantId: TenantId, asOf: Temporal.Instant, options?: {
24
30
  readonly includeArchived?: boolean;
25
31
  }): Promise<readonly StoredEvent[]>;
@@ -2,12 +2,13 @@
2
2
  import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
3
3
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
4
4
  import { requestContext } from "../api/request-context.js";
5
+ import { instantToTimestamptzParam } from "../bun-db/query.js";
5
6
  import { encryptEventPayloadPii } from "../crypto/event-pii.js";
6
7
  import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
7
8
  import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
8
9
  import { insertOne, selectMany } from "../db/query.js";
9
10
  import { isStreamArchived } from "./archive.js";
10
- import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
11
+ import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
11
12
  import { eventsTable } from "./events-schema.js";
12
13
  import { toStoredEvent } from "./row-to-stored-event.js";
13
14
  import { currentSeedCreatedAt } from "./seed-clock.js";
@@ -44,14 +45,16 @@ export async function append(db, event) {
44
45
  });
45
46
  const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
46
47
  const seedCreatedAt = currentSeedCreatedAt();
47
- if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
48
- await assertSeedTimeNotBeforePredecessor(db, toStore, seedCreatedAt);
49
- }
50
48
  const newVersion = toStore.expectedVersion + 1;
51
49
  const eventVersion = toStore.eventVersion ?? 1;
52
50
  try {
53
51
  const row = await runAtomicEventWrite(db, async (runner) => {
54
52
  await claimXactId(runner);
53
+ // Inside the write transaction so a concurrent live append cannot slip
54
+ // between the check and the insert.
55
+ if (seedCreatedAt !== undefined && toStore.expectedVersion > 0) {
56
+ await assertSeedTimeNotBeforePredecessor(runner, toStore, seedCreatedAt);
57
+ }
55
58
  const inserted = toStore.expectedVersion === 0
56
59
  ? await insertFirstEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt)
57
60
  : await insertSubsequentEvent(runner, toStore, newVersion, eventVersion, seedCreatedAt);
@@ -105,7 +108,7 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
105
108
  version: event.expectedVersion,
106
109
  });
107
110
  if (predecessor && predecessor.createdAt.epochMilliseconds > seedCreatedAt.epochMilliseconds) {
108
- throw new Error(`Seed write for ${event.aggregateType} ${event.aggregateId} v${event.expectedVersion + 1} is dated ${seedCreatedAt.toString()}, before its predecessor (${predecessor.createdAt.toString()}). Write a stream's events in ascending time.`);
111
+ throw new SeedTimeOrderError(event.aggregateType, event.aggregateId, event.expectedVersion + 1, seedCreatedAt, predecessor.createdAt);
109
112
  }
110
113
  }
111
114
  async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
@@ -141,7 +144,7 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
141
144
  metadata: event.metadata,
142
145
  createdBy: event.metadata.userId,
143
146
  expectedVersion: event.expectedVersion,
144
- createdAt: seedCreatedAt?.toString(),
147
+ createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
145
148
  });
146
149
  if (!row)
147
150
  throw new VersionConflictError(event.aggregateId, event.expectedVersion);
@@ -201,6 +204,25 @@ export async function loadAggregate(db, aggregateId, tenantId, options) {
201
204
  : { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
202
205
  return rows.map(toStoredEvent);
203
206
  }
207
+ // Events and the stream's latest version from ONE read, so both come from the same
208
+ // snapshot: a commit landing between two separate statements would leave the caller
209
+ // pinning a version its loaded events never covered. The aggregateType filter is applied
210
+ // after the read because a foreign type sharing the aggregateId still occupies versions
211
+ // the next append has to stack on. Archived streams load empty but keep their real version.
212
+ export async function loadAggregateForWriting(db, aggregateId, tenantId, options) {
213
+ if (options.aggregateType === "") {
214
+ throw new Error("loadAggregateForWriting: aggregateType must be a non-empty string");
215
+ }
216
+ if (await isStreamArchived(db, tenantId, aggregateId)) {
217
+ return { events: [], streamVersion: await getStreamVersion(db, aggregateId, tenantId) };
218
+ }
219
+ const rows = await selectMany(db, eventsTable, { aggregateId, tenantId }, { orderBy: { col: "version", direction: "asc" } });
220
+ const all = rows.map(toStoredEvent);
221
+ return {
222
+ events: all.filter((e) => e.aggregateType === options.aggregateType),
223
+ streamVersion: all.at(-1)?.version ?? 0,
224
+ };
225
+ }
204
226
  // Load events up to a point in time. Used for asOf queries that reconstruct
205
227
  // historical state. Includes events whose created_at is <= asOf. Same
206
228
  // archive semantics as loadAggregate.
@@ -1,7 +1,7 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.js";
3
3
  export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent, type ProvenanceEventInput } from "./provenance-append.js";
@@ -1,7 +1,7 @@
1
1
  export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
2
2
  export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
3
3
  export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
4
- export { ArchivedStreamError, IdempotentAppendConflictError, VersionConflictError, } from "./errors.js";
4
+ export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
5
5
  export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
6
6
  export { createEventsTable, eventsTable } from "./events-schema.js";
7
7
  export { appendProvenanceEvent } from "./provenance-append.js";