@cosmicdrift/kumiko-framework 0.342.0 → 0.344.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/api/auth-routes.d.ts +2 -0
  2. package/dist/api/auth-routes.js +35 -2
  3. package/dist/api/request-context.d.ts +2 -0
  4. package/dist/api/request-id-middleware.d.ts +2 -0
  5. package/dist/api/request-id-middleware.js +4 -1
  6. package/dist/api/routes.js +5 -5
  7. package/dist/api/server.d.ts +3 -0
  8. package/dist/api/server.js +4 -1
  9. package/dist/compliance/duration-spec.d.ts +2 -1
  10. package/dist/compliance/duration-spec.js +11 -0
  11. package/dist/compliance/index.d.ts +2 -2
  12. package/dist/compliance/index.js +1 -1
  13. package/dist/compliance/profiles.d.ts +6 -5
  14. package/dist/db/entity-field-encryption.d.ts +1 -1
  15. package/dist/db/entity-field-encryption.js +5 -5
  16. package/dist/db/event-store-executor-write.js +38 -11
  17. package/dist/db/projection-registry-binding.d.ts +3 -0
  18. package/dist/db/projection-registry-binding.js +12 -0
  19. package/dist/db/tenant-db.d.ts +2 -0
  20. package/dist/db/tenant-db.js +22 -7
  21. package/dist/engine/boot-validator/index.d.ts +11 -0
  22. package/dist/engine/boot-validator/index.js +45 -1
  23. package/dist/engine/boot-validator/query-output-columns.js +8 -7
  24. package/dist/engine/boot-validator/query-refs.js +3 -0
  25. package/dist/engine/boot-validator/screens.js +28 -0
  26. package/dist/engine/index.d.ts +2 -2
  27. package/dist/engine/index.js +1 -1
  28. package/dist/engine/types/index.d.ts +2 -2
  29. package/dist/engine/types/index.js +1 -1
  30. package/dist/env/index.d.ts +3 -0
  31. package/dist/env/index.js +12 -0
  32. package/dist/errors/docs-url.d.ts +7 -0
  33. package/dist/errors/docs-url.js +36 -0
  34. package/dist/errors/index.d.ts +2 -0
  35. package/dist/errors/index.js +1 -0
  36. package/dist/errors/kumiko-error.d.ts +0 -2
  37. package/dist/errors/kumiko-error.js +0 -24
  38. package/dist/errors/serialize.d.ts +3 -2
  39. package/dist/errors/serialize.js +4 -2
  40. package/dist/jobs/job-runner.js +31 -2
  41. package/dist/pipeline/dispatch-shared.js +3 -1
  42. package/dist/pipeline/event-retention.d.ts +1 -0
  43. package/dist/pipeline/event-retention.js +6 -0
  44. package/dist/pipeline/index.d.ts +2 -0
  45. package/dist/pipeline/index.js +1 -0
  46. package/dist/pipeline/projections-runner.d.ts +1 -0
  47. package/dist/pipeline/projections-runner.js +23 -14
  48. package/dist/rate-limit/middleware.js +1 -1
  49. package/dist/stack/test-stack.d.ts +7 -0
  50. package/dist/stack/test-stack.js +6 -0
  51. package/package.json +4 -4
  52. package/src/changes.json +32 -0
@@ -34,6 +34,50 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
34
34
  export { collectWriteHandlerQns } from "./nav.js";
35
35
  export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline.js";
36
36
  export { MAX_TRANSFER_DEPTH } from "./transfer-graph.js";
37
+ // Test stacks mount feature subsets, so a reference into a feature that is not
38
+ // mounted cannot be judged: it counts as present. A reference into a mounted
39
+ // feature must still resolve.
40
+ function mountedPrefixOf(qn) {
41
+ return qn.split(":", 1)[0] ?? qn;
42
+ }
43
+ class PrefixTolerantSet extends Set {
44
+ mountedFeatures;
45
+ constructor(members, mountedFeatures) {
46
+ super(members);
47
+ this.mountedFeatures = mountedFeatures;
48
+ }
49
+ has(qn) {
50
+ return super.has(qn) || !this.mountedFeatures.has(mountedPrefixOf(qn));
51
+ }
52
+ }
53
+ class PrefixTolerantMap extends Map {
54
+ mountedFeatures;
55
+ constructor(members, mountedFeatures) {
56
+ super(members);
57
+ this.mountedFeatures = mountedFeatures;
58
+ }
59
+ has(qn) {
60
+ return super.has(qn) || !this.mountedFeatures.has(mountedPrefixOf(qn));
61
+ }
62
+ }
63
+ /**
64
+ * Subset-safe slice of validateBoot for test stacks: nav references (screen,
65
+ * parent, workspace, tree actions), workspaces and nav cycles. The checks that
66
+ * need the whole app composition (screens, ref entities) run only in validateBoot.
67
+ */
68
+ export function validateNavBootSubset(rawFeatures) {
69
+ const features = dedupeFeatures(rawFeatures);
70
+ const mounted = new Set(features.map((f) => f.name));
71
+ const allNavQns = collectNavQns(features);
72
+ const tolerantScreens = new PrefixTolerantSet(collectScreenQns(features), mounted);
73
+ const tolerantNavs = new PrefixTolerantMap(allNavQns, mounted);
74
+ const tolerantWorkspaces = new PrefixTolerantMap(collectWorkspaceQns(features), mounted);
75
+ for (const feature of features) {
76
+ validateNavs(feature, tolerantScreens, tolerantNavs, tolerantWorkspaces);
77
+ validateWorkspaces(feature, tolerantNavs);
78
+ }
79
+ validateNavCycles(allNavQns);
80
+ }
37
81
  /**
38
82
  * Validates all feature configurations at boot time.
39
83
  * Throws on the first error found — fail fast.
@@ -187,7 +231,7 @@ export function validateBoot(rawFeatures, options) {
187
231
  // catches malformed keys (wrong length, bad base64) at boot instead of
188
232
  // on the first encrypted read in prod. An injected cipher (test seam,
189
233
  // custom KMS provider) satisfies the requirement without env keys.
190
- validateEntityFieldEncryptionAvailable(options?.env);
234
+ validateEntityFieldEncryptionAvailable(options?.env, options?.entityFieldCipher);
191
235
  }
192
236
  if (hasFileFields && !(options?.env ?? process.env)[FILE_STORAGE_PROVIDER_ENV]) {
193
237
  throw new Error(`${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`);
@@ -165,13 +165,13 @@ function checkDashboardStatPanelFields(queryHandlers, featureName, screenId, pan
165
165
  checkPanelField("deltaToneField", panel.deltaToneField);
166
166
  }
167
167
  // visibleWhen reads a flat record like stat panels; a typo'd field would hide
168
- // the panel forever without any message.
169
- function checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel) {
170
- // skip: panel is always visible, no field to check
171
- if (panel.visibleWhen === undefined)
168
+ // the panel or screen forever without any message.
169
+ function checkVisibleWhenField(queryHandlers, visibleWhen, owner) {
170
+ // skip: always visible, no field to check
171
+ if (visibleWhen === undefined)
172
172
  return;
173
- const { query, field } = panel.visibleWhen;
174
- checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}" visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
173
+ const { query, field } = visibleWhen;
174
+ checkFieldExists(getZodObjectShape(queryHandlers.get(query)?.outputSchema), field, () => `${owner} visibleWhen references field "${field}" which is not present in query "${query}"'s outputSchema.`);
175
175
  }
176
176
  function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen) {
177
177
  for (const panel of screen.panels) {
@@ -184,7 +184,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
184
184
  }
185
185
  }
186
186
  else if (panel.kind === "screen") {
187
- checkScreenPanelVisibleWhenField(queryHandlers, featureName, screenId, panel);
187
+ checkVisibleWhenField(queryHandlers, panel.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`);
188
188
  }
189
189
  else if (panel.kind === "list") {
190
190
  const rowShape = resolveListRowShape(queryHandlers, panel.query, `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`);
@@ -197,6 +197,7 @@ function checkDashboardOutputFields(queryHandlers, featureName, screenId, screen
197
197
  }
198
198
  }
199
199
  function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen) {
200
+ checkVisibleWhenField(queryHandlers, screen.visibleWhen, `[Feature ${featureName}] Screen "${screenId}" (${screen.type})`);
200
201
  if (screen.type === "projectionList") {
201
202
  checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
202
203
  }
@@ -52,6 +52,9 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
52
52
  }
53
53
  }
54
54
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
55
+ if (screen.visibleWhen !== undefined) {
56
+ checkQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
57
+ }
55
58
  if (screen.type === "projectionList") {
56
59
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
57
60
  }
@@ -636,6 +636,33 @@ function validateRecordActionRedirect(feature, screenId, screenKind, action, all
636
636
  validateRedirectTarget(feature, screenId, screenKind, action.redirect, allScreenQns);
637
637
  }
638
638
  }
639
+ function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
640
+ const { visibleWhen, fallback } = screen;
641
+ const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
642
+ if (visibleWhen === undefined && fallback !== undefined) {
643
+ throw new Error(`${context} declares fallback without visibleWhen.`);
644
+ }
645
+ if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
646
+ throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
647
+ }
648
+ if (fallback !== undefined) {
649
+ validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
650
+ }
651
+ }
652
+ function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
653
+ const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
654
+ validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
655
+ const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
656
+ if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
657
+ throw new Error(`${context} fallback points at the screen itself.`);
658
+ }
659
+ // A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
660
+ // runtime gate trivially terminating.
661
+ const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
662
+ if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
663
+ throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
664
+ }
665
+ }
639
666
  function validateInlineFormNavTargets(feature, screenId, screen, kind, allScreenQns) {
640
667
  if (screen.redirect !== undefined) {
641
668
  validateRedirectTarget(feature, screenId, kind, screen.redirect, allScreenQns);
@@ -869,6 +896,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
869
896
  };
870
897
  for (const [screenId, screen] of Object.entries(feature.screens)) {
871
898
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
899
+ validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap);
872
900
  if (screen.type === "custom") {
873
901
  if (!screen.renderer.react && !screen.renderer.native) {
874
902
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" has type="custom" but the renderer ` +
@@ -74,7 +74,7 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
74
74
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
75
75
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
76
76
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
77
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, 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, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, 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, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, 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, TenantId, 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";
78
- export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
77
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, 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, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, 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, NotificationRecipientFn, NotificationTemplateFn, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, 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, TenantId, 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";
78
+ export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
79
79
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
80
80
  export { runValidation } from "./validation.js";
@@ -68,5 +68,5 @@ export { WORKFLOW_SYSTEM_EVENT_DEFS } from "./steps/workflow-system-events.js";
68
68
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
69
69
  export { findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, } from "./tier-resolver-extension.js";
70
70
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
71
- export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
71
+ export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HookPhases, HTTP_ERROR_CODE_PATTERN, isAgentVisibleScreen, isDeliveryErrorCode, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
72
72
  export { runValidation } from "./validation.js";
@@ -7,8 +7,8 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
7
7
  export type { BootCheckContext, BootCheckFn, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
9
  export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
- export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
- export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
10
+ export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDelivery, NotifyDeliveryStatus, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadRateLimitOption, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
+ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
13
13
  export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
14
14
  export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
@@ -1,7 +1,7 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
3
  export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
4
- export { isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
4
+ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
5
5
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
6
6
  export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
7
7
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers.js";
@@ -57,6 +57,9 @@ export type ComposedEnvSchema = {
57
57
  * "app" from `extend`, or "framework-core" from `core`. */
58
58
  readonly sources: Readonly<Record<string, string>>;
59
59
  };
60
+ /** Makes the named env keys optional while keeping their `kms` meta. Keys
61
+ * missing from the schema shape are ignored. */
62
+ export declare function withOptionalEnvKeys(schema: z.ZodObject<z.ZodRawShape>, keys: readonly string[]): z.ZodObject<z.ZodRawShape>;
60
63
  export declare function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvSchema;
61
64
  export type EnvErrorKind = "missing" | "invalid";
62
65
  export type EnvError = {
package/dist/env/index.js CHANGED
@@ -123,6 +123,18 @@ function optionalKeepingKmsMeta(field) {
123
123
  ? optional.meta({ kumiko: readKumikoMeta(field) })
124
124
  : optional;
125
125
  }
126
+ /** Makes the named env keys optional while keeping their `kms` meta. Keys
127
+ * missing from the schema shape are ignored. */
128
+ export function withOptionalEnvKeys(schema, keys) {
129
+ const shape = zodShape(schema);
130
+ const relaxed = {};
131
+ for (const key of keys) {
132
+ const field = shape[key];
133
+ if (field)
134
+ relaxed[key] = optionalKeepingKmsMeta(field);
135
+ }
136
+ return Object.keys(relaxed).length === 0 ? schema : schema.safeExtend(relaxed);
137
+ }
126
138
  export function composeEnvSchema(options) {
127
139
  const optionalSet = new Set(options.optionalFeatures ?? []);
128
140
  const merged = {};
@@ -0,0 +1,7 @@
1
+ import type { KumikoError } from "./kumiko-error.js";
2
+ /** Links an app's own error reasons to its own docs; without it only framework reasons carry a docsUrl. */
3
+ export type ErrorDocsConfig = {
4
+ readonly baseUrl: string;
5
+ readonly reasons?: readonly string[] | "all";
6
+ };
7
+ export declare function resolveErrorDocsUrl(err: KumikoError, errorDocs?: ErrorDocsConfig): string | undefined;
@@ -0,0 +1,36 @@
1
+ import { AgentReasons, FrameworkReasons } from "./reasons.js";
2
+ const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
3
+ const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
4
+ const frameworkReasons = new Set([
5
+ ...Object.values(FrameworkReasons),
6
+ ...Object.values(AgentReasons),
7
+ ]);
8
+ function reasonOf(err) {
9
+ if (!err.details || typeof err.details !== "object")
10
+ return undefined;
11
+ // @cast-boundary error-details — per-error typed details; reflection shape only for the reason lookup.
12
+ const reason = err.details["reason"];
13
+ return typeof reason === "string" && REASON_SLUG_RE.test(reason) ? reason : undefined;
14
+ }
15
+ function isCoveredByConfig(reason, errorDocs) {
16
+ if (!errorDocs)
17
+ return false;
18
+ return errorDocs.reasons === "all" || (errorDocs.reasons?.includes(reason) ?? false);
19
+ }
20
+ function errorsUrl(baseUrl, slug) {
21
+ return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
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.
26
+ export function resolveErrorDocsUrl(err, errorDocs) {
27
+ const reason = reasonOf(err);
28
+ 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);
31
+ }
32
+ if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
33
+ return errorsUrl(errorDocs.baseUrl, reason);
34
+ }
35
+ return undefined;
36
+ }
@@ -1,5 +1,7 @@
1
1
  export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
2
2
  export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
3
+ export type { ErrorDocsConfig } from "./docs-url.js";
4
+ export { resolveErrorDocsUrl } from "./docs-url.js";
3
5
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
4
6
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
5
7
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
@@ -1,4 +1,5 @@
1
1
  export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
2
+ export { resolveErrorDocsUrl } from "./docs-url.js";
2
3
  export { isKumikoError, KumikoError } from "./kumiko-error.js";
3
4
  export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
4
5
  export { AgentReasons, FrameworkReasons } from "./reasons.js";
@@ -19,7 +19,5 @@ export declare abstract class KumikoError extends Error {
19
19
  readonly i18nParams: Readonly<Record<string, unknown>> | undefined;
20
20
  readonly details: unknown;
21
21
  constructor(input: ErrorCtorInput);
22
- get docsUrl(): string;
23
- private get reasonSlug();
24
22
  }
25
23
  export declare function isKumikoError(e: unknown): e is KumikoError;
@@ -2,13 +2,6 @@
2
2
  // Anything that isn't a KumikoError is treated as an unexpected crash and
3
3
  // auto-wrapped into InternalError by the dispatcher — so the HTTP layer never
4
4
  // has to guess the status or the client-facing shape.
5
- // Default docs URL for self-service errors. Override via `KUMIKO_DOCS_URL`
6
- // (e.g. self-hosted customers pointing at their own docs instance).
7
- const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
8
- const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
9
- function docsBaseUrl() {
10
- return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
11
- }
12
5
  export class KumikoError extends Error {
13
6
  i18nKey;
14
7
  i18nParams;
@@ -20,23 +13,6 @@ export class KumikoError extends Error {
20
13
  this.i18nParams = input.i18nParams;
21
14
  this.details = input.details;
22
15
  }
23
- // Docs URL for self-service. Prefer a well-formed `details.reason` slug
24
- // (e.g. ConflictError → "stale_state"); otherwise fall back to the error
25
- // code. Encode the path segment so spaces/`../` in free-form reasons
26
- // cannot break or redirect the link the default renderer shows.
27
- get docsUrl() {
28
- return `${docsBaseUrl()}/errors/${encodeURIComponent(this.reasonSlug)}`;
29
- }
30
- get reasonSlug() {
31
- if (this.details && typeof this.details === "object") {
32
- // @cast-boundary error-details — per-error typed details; reflection
33
- // shape only for the reasonSlug lookup.
34
- const r = this.details["reason"];
35
- if (typeof r === "string" && REASON_SLUG_RE.test(r))
36
- return r;
37
- }
38
- return this.code;
39
- }
40
16
  }
41
17
  export function isKumikoError(e) {
42
18
  return e instanceof KumikoError;
@@ -1,3 +1,4 @@
1
+ import { type ErrorDocsConfig } from "./docs-url.js";
1
2
  import type { KumikoError } from "./kumiko-error.js";
2
3
  export type ErrorResponseBody = {
3
4
  readonly error: {
@@ -6,12 +7,12 @@ export type ErrorResponseBody = {
6
7
  readonly i18nParams?: Readonly<Record<string, unknown>>;
7
8
  readonly message: string;
8
9
  readonly details?: unknown;
9
- readonly docsUrl: string;
10
+ readonly docsUrl?: string;
10
11
  readonly requestId?: string;
11
12
  readonly timestamp: string;
12
13
  };
13
14
  };
14
- export declare function serializeError(err: KumikoError, requestId?: string): ErrorResponseBody;
15
+ export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
15
16
  export type ErrorLogEntry = {
16
17
  readonly name: string;
17
18
  readonly code: string;
@@ -1,3 +1,4 @@
1
+ import { resolveErrorDocsUrl } from "./docs-url.js";
1
2
  // InternalError deliberately omits `details` from the response in production,
2
3
  // because that field often carries the internal cause. The log still has
3
4
  // everything (see buildErrorLog).
@@ -19,7 +20,7 @@ function devCauseDetail(cause) {
19
20
  ...(cause.stack && { causeStack: cause.stack.split("\n").slice(0, 8).join("\n") }),
20
21
  };
21
22
  }
22
- export function serializeError(err, requestId) {
23
+ export function serializeError(err, requestId, errorDocs) {
23
24
  const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && isProductionEnv();
24
25
  const exposeDeclaredDetails = err.details !== undefined && !stripped;
25
26
  // For internal_error in dev/test, surface the cause chain so the
@@ -34,6 +35,7 @@ export function serializeError(err, requestId) {
34
35
  }
35
36
  return undefined;
36
37
  })();
38
+ const docsUrl = resolveErrorDocsUrl(err, errorDocs);
37
39
  return {
38
40
  error: {
39
41
  code: err.code,
@@ -41,7 +43,7 @@ export function serializeError(err, requestId) {
41
43
  ...(err.i18nParams && { i18nParams: err.i18nParams }),
42
44
  message: err.message,
43
45
  ...(detailsForResponse !== undefined && { details: detailsForResponse }),
44
- docsUrl: err.docsUrl,
46
+ ...(docsUrl !== undefined && { docsUrl }),
45
47
  ...(requestId && { requestId }),
46
48
  timestamp: new Date().toISOString(),
47
49
  },
@@ -159,6 +159,22 @@ function missingDbProxy(jobName) {
159
159
  },
160
160
  });
161
161
  }
162
+ function createCrossTenantReads(systemModeDb, reason, report) {
163
+ return {
164
+ selectMany(table, where, options) {
165
+ report("cross-tenant-read", reason);
166
+ return systemModeDb.selectMany(table, where, options);
167
+ },
168
+ fetchOne(table, where) {
169
+ report("cross-tenant-read", reason);
170
+ return systemModeDb.fetchOne(table, where);
171
+ },
172
+ count(table, where) {
173
+ report("cross-tenant-read", reason);
174
+ return systemModeDb.count(table, where);
175
+ },
176
+ };
177
+ }
162
178
  function priorAttemptsOf(data) {
163
179
  const value = data["_priorAttempts"];
164
180
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
@@ -317,7 +333,11 @@ export function createJobRunner(options) {
317
333
  if (context.meter)
318
334
  registerStandardMetrics(context.meter);
319
335
  // Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
320
- const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup(["unsafe-raw", "global-write"]);
336
+ const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
337
+ "unsafe-raw",
338
+ "global-write",
339
+ "cross-tenant-read",
340
+ ]);
321
341
  // Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
322
342
  // clients itself, which fires the exact same 'error' listeners below with
323
343
  // an expected "Connection is closed." Downgrading to debug once stopping
@@ -690,7 +710,10 @@ export function createJobRunner(options) {
690
710
  // unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
691
711
  // ctx.systemDb, built from it, is gated too.
692
712
  const tenantScopedDb = configDb
693
- ? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined, jobPersonalDataGate ? { personalDataGate: jobPersonalDataGate } : undefined)
713
+ ? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined, {
714
+ projectionRegistry: registry,
715
+ ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
716
+ })
694
717
  : undefined;
695
718
  const isSystemJob = registry.isJobSystemScoped(jobName);
696
719
  // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
@@ -712,9 +735,14 @@ export function createJobRunner(options) {
712
735
  unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
713
736
  globalWrites: escapeHatchFor(jobDef.escapeHatch, "globalWrites"),
714
737
  report: reportEscapeHatch,
738
+ projectionRegistry: registry,
715
739
  ...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
716
740
  })
717
741
  : undefined;
742
+ const crossTenantReadsReason = escapeHatchFor(jobDef.escapeHatch, "unsafeRaw")?.reason;
743
+ const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
744
+ ? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
745
+ : undefined;
718
746
  const config = context._configAccessorFactory && tenantScopedDb
719
747
  ? context._configAccessorFactory({
720
748
  user: { id: jobSystemUser.id, tenantId },
@@ -743,6 +771,7 @@ export function createJobRunner(options) {
743
771
  ...(notify !== undefined && { notify }),
744
772
  ...(config !== undefined && { config }),
745
773
  ...(systemDb && { systemDb }),
774
+ ...(crossTenantReads && { crossTenantReads }),
746
775
  // The runner owns the registry it resolved this job from — expose it so
747
776
  // workers can reach projections/jobs without the app author duplicating
748
777
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -178,6 +178,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
178
178
  report: reportEscapeHatch,
179
179
  memberReadOnly: isMemberResolutionPrincipal(user),
180
180
  personalDataGate: buildPersonalDataGate(registry, origin),
181
+ projectionRegistry: registry,
181
182
  });
182
183
  // Propagate the request's AbortSignal so every TenantDb query throws when
183
184
  // the client has disconnected — handlers with many sequential queries skip
@@ -244,7 +245,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
244
245
  const targetSystemUser = createSystemUser(targetTenantId);
245
246
  return configFactory({
246
247
  user: { id: targetSystemUser.id, tenantId: targetTenantId },
247
- db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
248
+ db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter, undefined, { projectionRegistry: registry }),
248
249
  secrets: context.secrets,
249
250
  });
250
251
  }
@@ -983,6 +984,7 @@ function buildAuthClaimsContext(ctx, user) {
983
984
  log: context.log,
984
985
  window: ctx.escapeHatchReportWindow,
985
986
  }),
987
+ projectionRegistry: ctx.registry,
986
988
  });
987
989
  const configAccessor = context._configAccessorFactory
988
990
  ? context._configAccessorFactory({
@@ -4,6 +4,7 @@ export type PruneEventsOptions = {
4
4
  readonly olderThan?: Temporal.Instant;
5
5
  readonly olderThanDays?: number;
6
6
  readonly aggregateTypes: readonly string[];
7
+ readonly aggregateIds?: readonly string[];
7
8
  readonly dryRun?: boolean;
8
9
  };
9
10
  export type PruneEventsResult = {
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { lockEventConsumersShareMode } from "../db/queries/event-consumer.js";
2
3
  import { deleteMany, selectMany, transaction } from "../db/query.js";
3
4
  import { eventsTable } from "../event-store/index.js";
@@ -30,7 +31,11 @@ export async function pruneEvents(db, options) {
30
31
  if (!options.aggregateTypes || options.aggregateTypes.length === 0) {
31
32
  throw new Error("pruneEvents: aggregateTypes is required and must be non-empty. Pruning the event log is destructive — name the aggregate types to delete explicitly.");
32
33
  }
34
+ if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
35
+ throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
36
+ }
33
37
  const aggregateTypes = options.aggregateTypes;
38
+ const aggregateIds = options.aggregateIds;
34
39
  const dryRun = options.dryRun === true;
35
40
  return transaction(db, async (tx) => {
36
41
  // Serialise against consumer-bootstrap INSERTs. Without this, the race
@@ -49,6 +54,7 @@ export async function pruneEvents(db, options) {
49
54
  // Step 1 — collect candidate event ids.
50
55
  const candidates = await selectMany(tx, eventsTable, {
51
56
  aggregateType: [...aggregateTypes],
57
+ ...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
52
58
  createdAt: { lt: cutoff },
53
59
  });
54
60
  if (candidates.length === 0) {
@@ -5,6 +5,8 @@ export { resolveAuthClaims } from "./auth-claims-resolver.js";
5
5
  export { createCascadeDeleteHook } from "./cascade-handler.js";
6
6
  export type { Dispatcher } from "./dispatcher.js";
7
7
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
8
+ export type { FailedWriteResult } from "./dispatcher-utils.js";
9
+ export { isFailedWriteResult } from "./dispatcher-utils.js";
8
10
  export type { DistributedLock } from "./distributed-lock.js";
9
11
  export { createDistributedLock } from "./distributed-lock.js";
10
12
  export type { EntityCache, EntityCacheOptions } from "./entity-cache.js";
@@ -2,6 +2,7 @@ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_
2
2
  export { resolveAuthClaims } from "./auth-claims-resolver.js";
3
3
  export { createCascadeDeleteHook } from "./cascade-handler.js";
4
4
  export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
5
+ export { isFailedWriteResult } from "./dispatcher-utils.js";
5
6
  export { createDistributedLock } from "./distributed-lock.js";
6
7
  export { createEntityCache } from "./entity-cache.js";
7
8
  export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
@@ -2,4 +2,5 @@ import type { DbRunner } from "../db/index.js";
2
2
  import type { LifecycleResult, Registry } from "../engine/types/index.js";
3
3
  import type { StoredEvent } from "../event-store/index.js";
4
4
  export declare function runProjections(result: LifecycleResult, registry: Registry, runner: DbRunner | undefined): Promise<void>;
5
+ export declare function hasLiveProjectionsForEvent(event: StoredEvent, registry: Registry): boolean;
5
6
  export declare function runProjectionsForEvent(event: StoredEvent, registry: Registry, tx: DbRunner): Promise<void>;
@@ -36,22 +36,31 @@ export async function runProjections(result, registry, runner) {
36
36
  // CRUD path (via runProjections) and the ctx.appendEvent path (domain events
37
37
  // emitted inside a write handler). Keeping one function means an auto-event
38
38
  // and a r.defineEvent-event land in the same inline-projection pipeline.
39
+ // Events whose projections already ran. The EventStoreExecutor projects right
40
+ // after its write and hands the same object back on the LifecycleResult, so the
41
+ // dispatcher's later runProjections() and manual calls become no-ops instead of
42
+ // double-applying. Marked only after a successful run: a rolled-back write is
43
+ // retried with a fresh event object, so a thrown projection never blocks a retry.
44
+ const projectedEvents = new WeakSet();
45
+ // Implicit projections exist only for rebuildProjection: the EventStoreExecutor
46
+ // already writes their table on the live path, so applying them live would write
47
+ // twice and hit a unique key violation.
48
+ function liveProjectionsForEvent(event, registry) {
49
+ return registry
50
+ .getProjectionsForSource(event.aggregateType)
51
+ .filter((proj) => !proj.isImplicit && proj.apply[event.type] !== undefined);
52
+ }
53
+ export function hasLiveProjectionsForEvent(event, registry) {
54
+ return liveProjectionsForEvent(event, registry).length > 0;
55
+ }
39
56
  export async function runProjectionsForEvent(event, registry, tx) {
40
- const projections = registry.getProjectionsForSource(event.aggregateType);
41
- // skip: no projection feeds off this entity — fast path for the common case
42
- if (projections.length === 0)
57
+ // skip: this event object was already projected in this write
58
+ if (projectedEvents.has(event))
43
59
  return;
44
- for (const proj of projections) {
45
- // ImplicitProjections existieren nur für rebuildProjection — der
46
- // EventStoreExecutor schreibt im Live-Pfad bereits direkt in die
47
- // Tabelle. Live-Apply der Implicit würde doppelt schreiben → unique
48
- // key violation. Filter ist Pflicht.
49
- if (proj.isImplicit)
50
- continue;
60
+ for (const proj of liveProjectionsForEvent(event, registry)) {
51
61
  const applyFn = proj.apply[event.type];
52
- // skip: this projection doesn't care about this event type
53
- if (!applyFn)
54
- continue;
55
- await applyFn(event, tx, proj.table);
62
+ if (applyFn)
63
+ await applyFn(event, tx, proj.table);
56
64
  }
65
+ projectedEvents.add(event);
57
66
  }
@@ -128,5 +128,5 @@ function respondRateLimited(c, decision, bucket) {
128
128
  c.header("Retry-After", String(Math.max(1, decision.retryAfterSeconds)));
129
129
  setRateLimitHeaders(c, decision);
130
130
  const reqId = requestContext.get()?.requestId;
131
- return c.json(serializeError(err, reqId), 429);
131
+ return c.json(serializeError(err, reqId, requestContext.get()?.errorDocs), 429);
132
132
  }