@cosmicdrift/kumiko-framework 0.393.0 → 0.394.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 (92) hide show
  1. package/AGENTS.md +1 -1
  2. package/agents/examples/cross-feature-events.md +87 -0
  3. package/agents/examples/dashboard-stats.md +141 -2
  4. package/agents/examples/env-composition.md +7 -7
  5. package/agents/examples/inbound-mail-triage.md +12 -0
  6. package/agents/examples/pdf-audit-export.md +103 -50
  7. package/agents/features.md +15 -13
  8. package/dist/api/entity-read-access.d.ts +1 -0
  9. package/dist/api/entity-read-access.js +20 -5
  10. package/dist/api/server-event-dispatcher.js +23 -2
  11. package/dist/compliance/profiles.js +4 -4
  12. package/dist/crypto/index.d.ts +1 -0
  13. package/dist/crypto/index.js +1 -0
  14. package/dist/crypto/kms-outage.d.ts +8 -0
  15. package/dist/crypto/kms-outage.js +33 -0
  16. package/dist/crypto/pii-field-encryption.js +4 -3
  17. package/dist/engine/boot-validator/entity-handler.d.ts +0 -1
  18. package/dist/engine/boot-validator/entity-handler.js +2 -16
  19. package/dist/engine/boot-validator/query-output-columns.js +7 -0
  20. package/dist/engine/boot-validator/query-refs.js +9 -0
  21. package/dist/engine/boot-validator/screens-actions.js +19 -0
  22. package/dist/engine/boot-validator/screens-dashboard.d.ts +3 -2
  23. package/dist/engine/boot-validator/screens-dashboard.js +13 -6
  24. package/dist/engine/boot-validator/screens-kind-entity-list.js +2 -0
  25. package/dist/engine/boot-validator/screens-kind-projection-list.js +2 -0
  26. package/dist/engine/boot-validator/screens-lists.js +3 -0
  27. package/dist/engine/boot-validator/selector-owner-panels.js +1 -1
  28. package/dist/engine/boot-validator/transaction-phases.d.ts +3 -0
  29. package/dist/engine/boot-validator/transaction-phases.js +28 -0
  30. package/dist/engine/define-handler.d.ts +3 -2
  31. package/dist/engine/define-handler.js +3 -0
  32. package/dist/engine/factories.d.ts +2 -0
  33. package/dist/engine/feature-config-events-jobs.js +3 -0
  34. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  35. package/dist/engine/feature-entity-handlers.js +1 -17
  36. package/dist/engine/handler-definition-slots.d.ts +17 -0
  37. package/dist/engine/handler-definition-slots.js +20 -0
  38. package/dist/engine/qualified-name.d.ts +2 -1
  39. package/dist/engine/qualified-name.js +2 -14
  40. package/dist/engine/registry-facade.js +6 -0
  41. package/dist/engine/registry-read-models.d.ts +2 -0
  42. package/dist/engine/registry-read-models.js +24 -0
  43. package/dist/engine/registry-state.d.ts +4 -0
  44. package/dist/engine/registry-state.js +1 -0
  45. package/dist/engine/registry-validate.js +12 -1
  46. package/dist/engine/registry.js +2 -0
  47. package/dist/engine/strip-disabled-features-from-app-schema.js +9 -6
  48. package/dist/events-cli.d.ts +19 -0
  49. package/dist/events-cli.js +121 -0
  50. package/dist/files/bind-pending-file-refs.js +2 -1
  51. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  52. package/dist/files/can-caller-read-file-ref.d.ts +10 -0
  53. package/dist/files/can-caller-read-file-ref.js +12 -0
  54. package/dist/files/create-file-ref-from-bytes.d.ts +2 -0
  55. package/dist/files/create-file-ref-from-bytes.js +7 -2
  56. package/dist/files/create-file-ref.d.ts +4 -12
  57. package/dist/files/create-file-ref.js +2 -45
  58. package/dist/files/default-file-guard.d.ts +4 -0
  59. package/dist/files/default-file-guard.js +41 -0
  60. package/dist/files/delete-record-file-refs.d.ts +8 -0
  61. package/dist/files/delete-record-file-refs.js +20 -0
  62. package/dist/files/file-routes.d.ts +1 -0
  63. package/dist/files/file-routes.js +9 -36
  64. package/dist/files/index.d.ts +6 -2
  65. package/dist/files/index.js +4 -1
  66. package/dist/files/record-only-file-entity-types.d.ts +2 -0
  67. package/dist/files/record-only-file-entity-types.js +22 -0
  68. package/dist/files/record-read-check.js +11 -0
  69. package/dist/files/types.d.ts +2 -1
  70. package/dist/files/types.js +1 -1
  71. package/dist/files/upload-field-resolution.d.ts +24 -0
  72. package/dist/files/upload-field-resolution.js +51 -0
  73. package/dist/jobs/job-runner-event-triggers.d.ts +12 -0
  74. package/dist/jobs/job-runner-event-triggers.js +64 -0
  75. package/dist/jobs/job-runner-helpers.d.ts +5 -0
  76. package/dist/jobs/job-runner-helpers.js +10 -0
  77. package/dist/jobs/job-runner-types.d.ts +1 -0
  78. package/dist/jobs/job-runner.js +3 -51
  79. package/dist/pipeline/aggregate-type-removal.d.ts +37 -0
  80. package/dist/pipeline/aggregate-type-removal.js +146 -0
  81. package/dist/pipeline/catch-up-job-consumer.d.ts +15 -0
  82. package/dist/pipeline/catch-up-job-consumer.js +38 -0
  83. package/dist/pipeline/dispatch-write.js +4 -9
  84. package/dist/pipeline/job-trigger-consumer.d.ts +5 -0
  85. package/dist/pipeline/job-trigger-consumer.js +48 -0
  86. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -2
  87. package/dist/pipeline/pre-transaction-reservations.js +16 -0
  88. package/dist/pipeline/system-hooks.d.ts +0 -3
  89. package/dist/pipeline/system-hooks.js +0 -48
  90. package/dist/ui-types/index.d.ts +1 -1
  91. package/package.json +11 -3
  92. package/src/changes.json +42 -0
@@ -1,12 +1,13 @@
1
1
  import type { CursorResult } from "@cosmicdrift/kumiko-types/cursor-types";
2
2
  import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
3
+ import type { BeforeTransactionWhenNested } from "@cosmicdrift/kumiko-types/handlers";
3
4
  import type { ZodType } from "zod";
4
5
  import type { ContainsSecret } from "../secrets/types.ts";
5
6
  import type { QueryHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput } from "./types/define-handler.ts";
6
7
  export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "./types/define-handler.ts";
7
- export declare function defineWriteHandler<const TName extends string, TSchema extends ZodType, TData = unknown, TMap extends object = KumikoEventTypeMap, TPrepared = undefined>(def: WriteHandlerInput<TName, TSchema, TData, TMap, TPrepared>, ..._noSecretInResponse: true extends ContainsSecret<TData> ? [
8
+ export declare function defineWriteHandler<const TName extends string, TSchema extends ZodType, TData = unknown, TMap extends object = KumikoEventTypeMap, TPrepared = undefined, TWhenNested extends BeforeTransactionWhenNested | undefined = undefined>(def: WriteHandlerInput<TName, TSchema, TData, TMap, TPrepared, TWhenNested>, ..._noSecretInResponse: true extends ContainsSecret<TData> ? [
8
9
  secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field."
9
- ] : []): WriteHandlerDefinition<TName, TSchema, TData, TMap, TPrepared>;
10
+ ] : []): WriteHandlerDefinition<TName, TSchema, TData, TMap, TPrepared, TWhenNested>;
10
11
  export declare function defineQueryHandler<const TName extends string, TSchema extends ZodType, TResult = unknown, TMap extends object = KumikoEventTypeMap>(def: QueryHandlerDefinition<TName, TSchema, TResult, TMap>, ..._noSecretInResponse: true extends ContainsSecret<TResult> ? [
11
12
  secretLeak: "A handler response must not contain a Secret<> — call .reveal() and return the plaintext, or drop the field."
12
13
  ] : []): QueryHandlerDefinition<TName, TSchema, TResult, TMap>;
@@ -5,6 +5,9 @@ function pickPreTransactionPhases(def) {
5
5
  return {
6
6
  ...(def.reserveBeforeTransaction && { reserveBeforeTransaction: def.reserveBeforeTransaction }),
7
7
  ...(def.beforeTransaction && { beforeTransaction: def.beforeTransaction }),
8
+ ...(def.beforeTransactionWhenNested && {
9
+ beforeTransactionWhenNested: def.beforeTransactionWhenNested,
10
+ }),
8
11
  };
9
12
  }
10
13
  // `in` keeps both branches of the discriminated union readable regardless of narrowing.
@@ -199,4 +199,6 @@ export declare function createEntity<const F, const T extends EntityTenancy = "t
199
199
  readonly tenancy?: T;
200
200
  /** See EntityDefinition.transferable. */
201
201
  readonly transferable?: boolean;
202
+ /** See EntityDefinition.attachedFileAccess. */
203
+ readonly attachedFileAccess?: EntityDefinition["attachedFileAccess"];
202
204
  }): F extends FieldsMap ? EntityDefinition<F, T> : never;
@@ -108,6 +108,9 @@ export function buildConfigEventsJobsMethods(state, name) {
108
108
  ...(jobOptions.trigger.where !== undefined
109
109
  ? { where: jobOptions.trigger.where }
110
110
  : {}),
111
+ ...(jobOptions.trigger.catchUp !== undefined
112
+ ? { catchUp: jobOptions.trigger.catchUp }
113
+ : {}),
111
114
  }
112
115
  : jobOptions.trigger;
113
116
  state.jobs[jobName] = { ...jobOptions, trigger, name: jobName, handler: jobHandler };
@@ -1,7 +1,7 @@
1
1
  import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
2
2
  import type { EntityObjectDefinition } from "@cosmicdrift/kumiko-types/feature-registrar";
3
3
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
4
- import type { AccessRule, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, RateLimitDeclaration, StreamHandlerFn } from "@cosmicdrift/kumiko-types/handlers";
4
+ import type { AccessRule, BeforeTransactionWhenNested, EntityRef, EscapeHatchDeclaration, HandlerRef, NameOrRef, RateLimitDeclaration, StreamHandlerFn } from "@cosmicdrift/kumiko-types/handlers";
5
5
  import type { RelationDefinition } from "@cosmicdrift/kumiko-types/relations";
6
6
  import type * as z from "zod";
7
7
  import type { ZodType } from "zod";
@@ -14,7 +14,7 @@ export declare function buildEntityHandlerMethods<TName extends string>(state: F
14
14
  readonly entity: NameOrRef;
15
15
  readonly name: string;
16
16
  } & RelationDefinition), relationName?: string, definition?: RelationDefinition): void;
17
- writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined>(def: WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared>): HandlerRef;
17
+ writeHandler<TName_1 extends string, TSchema extends ZodType, TPrepared = undefined, TWhenNested extends BeforeTransactionWhenNested | undefined = undefined>(def: WriteHandlerDefinition<TName_1, TSchema, unknown, KumikoEventTypeMap, TPrepared, TWhenNested>): HandlerRef;
18
18
  queryHandler<TName_1 extends string, TSchema extends ZodType>(def: QueryHandlerDefinition<TName_1, TSchema>): HandlerRef;
19
19
  streamHandler<TName_1 extends string, TSchema extends ZodType>(nameOrDef: string | StreamHandlerDefinition<TName_1, TSchema>, schema?: TSchema, handler?: StreamHandlerFn<z.infer<TSchema>>, options?: {
20
20
  access: AccessRule;
@@ -2,6 +2,7 @@ import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handler
2
2
  import { toTableName } from "../db/table-builder.js";
3
3
  import { isEntityConventionQueryHandler, isPagedQueryHandler, PAGED_QUERY_HANDLER_BRAND, } from "./define-handler.js";
4
4
  import { assertEscapeHatchAccessIsSystemAdminOnly, registerCrudHandlers, } from "./entity-handlers.js";
5
+ import { agentSlots, transactionPhaseSlots } from "./handler-definition-slots.js";
5
6
  import { resolveName } from "./handler-helpers.js";
6
7
  import { isObjectArgument, requireArgument, throwObjectArgumentRequired } from "./object-form.js";
7
8
  const CRUD_VERBS = new Set(["create", "update", "delete"]);
@@ -48,23 +49,6 @@ function tryMapEntity(state, name, handlerName) {
48
49
  }
49
50
  }
50
51
  }
51
- function agentSlots(source) {
52
- return {
53
- ...(source.description !== undefined && { description: source.description }),
54
- ...(source.agent && { agent: source.agent }),
55
- };
56
- }
57
- function transactionPhaseSlots(def) {
58
- return {
59
- ...(def.reserveBeforeTransaction && {
60
- reserveBeforeTransaction: def.reserveBeforeTransaction,
61
- }),
62
- ...(def.beforeTransaction && {
63
- beforeTransaction: def.beforeTransaction, // @cast-boundary engine-bridge
64
- }),
65
- ...(def.transaction && { transaction: def.transaction }),
66
- };
67
- }
68
52
  // Typed callers cannot omit `access`; untyped ones (JS, designer output) must not get a
69
53
  // handler that silently falls through to the boot-time default-deny.
70
54
  function assertHandlerHasAccess(featureName, method, handlerName, access) {
@@ -0,0 +1,17 @@
1
+ import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-types/event-type-map";
2
+ import type { AgentHandlerHints, BeforeTransactionPhase, BeforeTransactionWhenNested } from "@cosmicdrift/kumiko-types/handlers";
3
+ import type { ZodType } from "zod";
4
+ import type { WriteHandlerDefinition } from "./define-handler.ts";
5
+ export declare function agentSlots(source: {
6
+ readonly description?: string;
7
+ readonly agent?: AgentHandlerHints;
8
+ }): {
9
+ description?: string;
10
+ agent?: AgentHandlerHints;
11
+ };
12
+ export declare function transactionPhaseSlots<TSchema extends ZodType, TPrepared, TWhenNested extends BeforeTransactionWhenNested | undefined>(def: Pick<WriteHandlerDefinition<string, TSchema, unknown, KumikoEventTypeMap, TPrepared, TWhenNested>, "reserveBeforeTransaction" | "beforeTransaction" | "beforeTransactionWhenNested" | "transaction">): {
13
+ reserveBeforeTransaction?: import("@cosmicdrift/kumiko-types/handlers").PreTransactionReservation | undefined;
14
+ beforeTransaction?: BeforeTransactionPhase | undefined;
15
+ beforeTransactionWhenNested?: NonNullable<TWhenNested> | undefined;
16
+ transaction?: "perCommand" | undefined;
17
+ };
@@ -0,0 +1,20 @@
1
+ export function agentSlots(source) {
2
+ return {
3
+ ...(source.description !== undefined && { description: source.description }),
4
+ ...(source.agent && { agent: source.agent }),
5
+ };
6
+ }
7
+ export function transactionPhaseSlots(def) {
8
+ return {
9
+ ...(def.reserveBeforeTransaction && {
10
+ reserveBeforeTransaction: def.reserveBeforeTransaction,
11
+ }),
12
+ ...(def.beforeTransaction && {
13
+ beforeTransaction: def.beforeTransaction, // @cast-boundary engine-bridge
14
+ }),
15
+ ...(def.beforeTransactionWhenNested && {
16
+ beforeTransactionWhenNested: def.beforeTransactionWhenNested,
17
+ }),
18
+ ...(def.transaction && { transaction: def.transaction }),
19
+ };
20
+ }
@@ -1,3 +1,5 @@
1
+ import { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
2
+ export { toKebab };
1
3
  export declare const QnTypes: {
2
4
  readonly write: "write";
3
5
  readonly query: "query";
@@ -24,4 +26,3 @@ export declare function isValidQn(value: string): boolean;
24
26
  export declare function isKebabSegment(name: string): boolean;
25
27
  export declare function qnScope(qualifiedName: string): string | undefined;
26
28
  export declare function qualifyEntityName(featureName: string, type: QnType, shortName: string): string;
27
- export declare function toKebab(input: string): string;
@@ -8,6 +8,8 @@
8
8
  // "tasks:write:task:create" → scope=tasks, type=write, name=task:create
9
9
  // "system:hook:search-index" → scope=system, type=hook, name=search-index
10
10
  // "billing:notify:invoice-sent" → scope=billing, type=notify, name=invoice-sent
11
+ import { toKebab } from "@cosmicdrift/kumiko-types/kebab-case";
12
+ export { toKebab };
11
13
  // Built-in QN types used by the framework.
12
14
  // Features can define additional types — the type segment is validated for format only, not membership.
13
15
  export const QnTypes = {
@@ -122,17 +124,3 @@ export function qnScope(qualifiedName) {
122
124
  export function qualifyEntityName(featureName, type, shortName) {
123
125
  return qn(toKebab(featureName), type, toKebab(shortName));
124
126
  }
125
- // Convert camelCase or dot.separated strings to kebab-case.
126
- // "task.create" → "task-create"
127
- // "ticketAssigned" → "ticket-assigned"
128
- // "billing-period.create" → "billing-period-create"
129
- // "monthlyReport" → "monthly-report"
130
- // Already kebab-case → unchanged
131
- // Colons are preserved: "task:create" → "task:create"
132
- export function toKebab(input) {
133
- return input
134
- .replace(/\./g, "-") // dots → dashes
135
- .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") // consecutive uppercase: SSEBroadcast → SSE-Broadcast
136
- .replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase boundaries: ticketAssigned → ticket-Assigned
137
- .toLowerCase();
138
- }
@@ -15,6 +15,12 @@ export function buildRegistryFacade(state) {
15
15
  getEntity(name) {
16
16
  return state.entityMap.get(name);
17
17
  },
18
+ getReadModelEntity(name) {
19
+ return state.readModelEntityMap.get(name)?.entity;
20
+ },
21
+ getReadModelEntityOwner(name) {
22
+ return state.readModelEntityMap.get(name)?.featureName;
23
+ },
18
24
  getAllEntities() {
19
25
  return state.entityMap;
20
26
  },
@@ -0,0 +1,2 @@
1
+ import type { RegistryState } from "./registry-state.ts";
2
+ export declare function populateReadModelEntities(state: RegistryState): void;
@@ -0,0 +1,24 @@
1
+ // Explicit projections that carry an entity make that entity addressable by file
2
+ // bindings and the file read check, keyed by the source aggregate type. They stay out of
3
+ // entityMap on purpose: CRUD, preSave, write checks and soft-delete cleanup hang off it.
4
+ export function populateReadModelEntities(state) {
5
+ const registeredBy = new Map();
6
+ for (const [qualifiedName, projection] of state.projectionMap) {
7
+ const { entity, source } = projection;
8
+ if (projection.isImplicit || entity === undefined || typeof source !== "string")
9
+ continue;
10
+ if (state.entityMap.has(source))
11
+ continue;
12
+ const previous = registeredBy.get(source);
13
+ if (previous !== undefined) {
14
+ throw new Error(`Read-model entity "${source}" is declared by two projections ("${previous}" and ` +
15
+ `"${qualifiedName}"). File bindings address the entity by its source, so each ` +
16
+ "source may carry an entity in one projection only.");
17
+ }
18
+ registeredBy.set(source, qualifiedName);
19
+ state.readModelEntityMap.set(source, {
20
+ entity,
21
+ featureName: qualifiedName.split(":")[0] ?? qualifiedName,
22
+ });
23
+ }
24
+ }
@@ -27,6 +27,10 @@ export declare const qualify: typeof qualifyEntityName;
27
27
  export type RegistryState = {
28
28
  featureMap: Map<string, FeatureDefinition>;
29
29
  entityMap: Map<string, EntityDefinition>;
30
+ readModelEntityMap: Map<string, {
31
+ readonly entity: EntityDefinition;
32
+ readonly featureName: string;
33
+ }>;
30
34
  relationMap: Map<string, Record<string, RelationDefinition>>;
31
35
  writeHandlerMap: Map<string, WriteHandlerDef>;
32
36
  queryHandlerMap: Map<string, QueryHandlerDef>;
@@ -123,6 +123,7 @@ export function createInitialState() {
123
123
  return {
124
124
  featureMap: new Map(),
125
125
  entityMap: new Map(),
126
+ readModelEntityMap: new Map(),
126
127
  relationMap: new Map(),
127
128
  writeHandlerMap: new Map(),
128
129
  queryHandlerMap: new Map(),
@@ -682,7 +682,7 @@ export function resolveJobTriggers(state) {
682
682
  // existing r.defineEvent registration. The latter is delivered async via the
683
683
  // job-trigger event-consumer (server.ts), not the synchronous write-handler
684
684
  // dispatch path — see createJobTriggerEventConsumer in
685
- // pipeline/system-hooks.ts. Short names are qualified against the job's own
685
+ // pipeline/job-trigger-consumer.ts. Short names are qualified against the job's own
686
686
  // feature and written back, because the job runner matches by exact QN.
687
687
  const allHandlers = allHandlerQns(state);
688
688
  for (const [jobName, jobDef] of state.jobMap) {
@@ -694,6 +694,14 @@ export function resolveJobTriggers(state) {
694
694
  const resolvedOn = isNameOrRefList(triggerOn)
695
695
  ? triggerOn.map(resolveTrigger)
696
696
  : resolveTrigger(triggerOn);
697
+ if (jobDef.trigger.catchUp) {
698
+ const targets = isNameOrRefList(resolvedOn) ? resolvedOn : [resolvedOn];
699
+ const notEvents = targets.filter((qn) => !state.eventMap.has(qn) || allHandlers.has(qn));
700
+ if (notEvents.length > 0) {
701
+ throw new Error(`Job "${jobName}" sets trigger.catchUp but ${notEvents.map((qn) => `"${qn}"`).join(", ")} ` +
702
+ `is not an r.defineEvent event. catchUp replays stored events, so every "on" entry must be an event.`);
703
+ }
704
+ }
697
705
  state.jobMap.set(jobName, { ...jobDef, trigger: { ...jobDef.trigger, on: resolvedOn } });
698
706
  }
699
707
  }
@@ -783,6 +791,9 @@ export function validateMaxPerTenant(state) {
783
791
  if (!Number.isInteger(resolved.limit) || resolved.limit <= 0) {
784
792
  throw new Error(`Job "${jobName}" maxPerTenant limit must be a positive integer (got ${resolved.limit})`);
785
793
  }
794
+ if (resolved.onLimit === "skip" && "on" in jobDef.trigger && jobDef.trigger.catchUp) {
795
+ throw new Error(`Job "${jobName}" cannot combine trigger.catchUp with maxPerTenant onLimit "skip" (a plain number is skip): a replay must not discard history. Use onLimit "defer".`);
796
+ }
786
797
  if (typeof jobDef.maxPerTenant !== "object")
787
798
  continue;
788
799
  const { deferMs } = jobDef.maxPerTenant;
@@ -2,6 +2,7 @@ import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler.
2
2
  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
+ import { populateReadModelEntities } from "./registry-read-models.js";
5
6
  import { createInitialState } from "./registry-state.js";
6
7
  import { applyExtensionUsages, autoWireSoftDeleteJobs, buildEntityEventUpcasterChains, buildEventUpcasterChains, buildImplicitProjections, buildIncomingRelations, buildSearchableSortableCaches, computeHasRateLimitedHandler, finalizeWorkspaceNavMembership, populateHandlerEntityMappings, publishEventPiiCatalog, resolveJobTriggers, resolveNotificationTriggersAndRegisterHooks, validateBootGates, validateEntityHookTargets, validateEventMigrationVersions, validateExtensionSelectors, validateExtensionUsageTargets, validateFieldAccessHandlersAreEntityMapped, validateJobBackoff, validateLifecycleHookTargets, validateLiveEntities, validateMaxDefers, validateMaxPerTenant, validateNotificationRecordMappings, validateNotificationRenamedFrom, validateProjectionApplyKeys, validateRelationTargetsExist, validateRequiredFeatures, } from "./registry-validate.js";
7
8
  // This is where the magic happens. By "magic" I mean: precomputed maps.
@@ -31,6 +32,7 @@ export function createRegistry(rawFeatures) {
31
32
  applyExtensionUsages(state);
32
33
  buildSearchableSortableCaches(state);
33
34
  buildImplicitProjections(state, features);
35
+ populateReadModelEntities(state);
34
36
  buildIncomingRelations(state);
35
37
  validateFieldAccessHandlersAreEntityMapped(state, features);
36
38
  validateExtensionPreSaveWiring(features);
@@ -11,6 +11,7 @@
11
11
  // gone it also drops every action, redirect, nav and workspace member that
12
12
  // pointed at them. The one reference it does not resolve is a nav `parent`
13
13
  // pointing at a missing nav; that is pruned here.
14
+ import { fallbackTargets } from "@cosmicdrift/kumiko-types/screen";
14
15
  import { createFallbackLogger, scrubLogText } from "../logging/index.js";
15
16
  import { resolveScreenTargetQn } from "./boot-validator/screens.js";
16
17
  import { workspaceOwnerOf } from "./build-app-schema.js";
@@ -107,12 +108,14 @@ function screensKeptForFallback(feature) {
107
108
  const fallback = pending.pop()?.fallback;
108
109
  if (fallback === undefined)
109
110
  continue;
110
- const targetQn = resolveScreenTargetQn(feature.featureName, fallback);
111
- const target = byQn.get(targetQn);
112
- if (target === undefined || kept.has(targetQn))
113
- continue;
114
- kept.add(targetQn);
115
- pending.push(target);
111
+ for (const fallbackTarget of fallbackTargets(fallback)) {
112
+ const targetQn = resolveScreenTargetQn(feature.featureName, fallbackTarget);
113
+ const target = byQn.get(targetQn);
114
+ if (target === undefined || kept.has(targetQn))
115
+ continue;
116
+ kept.add(targetQn);
117
+ pending.push(target);
118
+ }
116
119
  }
117
120
  return feature.screens.filter((screen) => kept.has(qnOf(screen.id)));
118
121
  }
@@ -0,0 +1,19 @@
1
+ import type { ConsumerCliOut } from "./consumer-cli-internals.ts";
2
+ import { type RowLevelSecurityMode } from "./db/render-rls-ddl.ts";
3
+ import type { FeatureDefinition } from "./engine/types/feature.ts";
4
+ export type RunEventsCliOptions = {
5
+ /** The composed app features: needed to validate types and to find the projections they feed. */
6
+ readonly features: readonly FeatureDefinition[];
7
+ /** The mode the app runs with (`AppConfig.rowLevelSecurity`); the removal runs in system scope. */
8
+ readonly rowLevelSecurity?: RowLevelSecurityMode;
9
+ };
10
+ export type ParsedEventsPruneArgs = {
11
+ readonly ok: true;
12
+ readonly types: readonly string[];
13
+ readonly apply: boolean;
14
+ } | {
15
+ readonly ok: false;
16
+ readonly error: string;
17
+ };
18
+ export declare function parseEventsPruneArgs(argv: readonly string[]): ParsedEventsPruneArgs;
19
+ export declare function runEventsCli(argv: readonly string[], out: ConsumerCliOut, options: RunEventsCliOptions): Promise<number>;
@@ -0,0 +1,121 @@
1
+ // Standalone event-log ops CLI (`kumiko events prune`) for the production bundle, same shape as
2
+ // consumer-cli.ts: one runEventsCli(argv, out, options) entry point with its own DB connection.
3
+ // Exists because old events of a retired aggregate type can fail the rebuild that `schema apply`
4
+ // runs, and no other surface removes a type from the log.
5
+ import { createDbConnection, dbConnectionOptionsFromEnv } from "./db/connection.js";
6
+ import { DEFAULT_ROW_LEVEL_SECURITY_MODE } from "./db/render-rls-ddl.js";
7
+ import { createRegistry } from "./engine/registry.js";
8
+ import { removeAggregateTypes, } from "./pipeline/aggregate-type-removal.js";
9
+ import { ensureTemporalPolyfill } from "./time/index.js";
10
+ const AGGREGATE_TYPE_FLAG = "--aggregate-type";
11
+ // Accepts "--aggregate-type <t>" and "--aggregate-type=<t>", repeatable. An unknown flag is an
12
+ // error rather than ignored: a typo such as "--aggregate-types" must not turn into "delete nothing"
13
+ // or, worse, into a partial selection.
14
+ export function parseEventsPruneArgs(argv) {
15
+ const types = [];
16
+ let apply = false;
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const arg = argv[i];
19
+ if (arg === undefined)
20
+ continue;
21
+ if (arg === "--apply") {
22
+ apply = true;
23
+ }
24
+ else if (arg === AGGREGATE_TYPE_FLAG) {
25
+ const value = argv[i + 1];
26
+ if (value === undefined || value.startsWith("--")) {
27
+ return { ok: false, error: `${AGGREGATE_TYPE_FLAG} needs a value.` };
28
+ }
29
+ types.push(value);
30
+ i++;
31
+ }
32
+ else if (arg.startsWith(`${AGGREGATE_TYPE_FLAG}=`)) {
33
+ types.push(arg.slice(AGGREGATE_TYPE_FLAG.length + 1));
34
+ }
35
+ else {
36
+ return { ok: false, error: `Unknown argument: ${arg}` };
37
+ }
38
+ }
39
+ if (types.length === 0) {
40
+ return { ok: false, error: `At least one ${AGGREGATE_TYPE_FLAG} is required.` };
41
+ }
42
+ if (types.some((type) => type.trim().length === 0)) {
43
+ return { ok: false, error: `${AGGREGATE_TYPE_FLAG} must not be empty.` };
44
+ }
45
+ return { ok: true, types: [...new Set(types)], apply };
46
+ }
47
+ function printHelp(write) {
48
+ write("");
49
+ write(" Subcommands:");
50
+ write(" prune --aggregate-type <type> [--aggregate-type <type> ...] [--apply]");
51
+ write(" Removes every event, snapshot, archive marker and dead letter of the given");
52
+ write(" aggregate types, and empties the projections fed only by them.");
53
+ write(" Without --apply it only reports counts.");
54
+ write("");
55
+ }
56
+ function report(out, result) {
57
+ const { plan, deleted } = result;
58
+ out.log("");
59
+ for (const s of plan.stats) {
60
+ out.log(` ${s.aggregateType}: ${s.events} events, ${s.aggregates} aggregates, ${s.tenants} tenants`);
61
+ }
62
+ for (const wiped of plan.wipedProjections) {
63
+ const rows = deleted?.projectionRows[wiped.projection];
64
+ out.log(` projection ${wiped.projection} (${wiped.table.tableName}): ${rows === undefined ? "all rows would be deleted" : `${rows} rows deleted`}`);
65
+ }
66
+ if (deleted === undefined) {
67
+ out.log("");
68
+ out.log(" Dry run, nothing deleted. Re-run with --apply to remove the above.");
69
+ }
70
+ else {
71
+ out.log("");
72
+ out.log(` Deleted ${deleted.events} events, ${deleted.snapshots} snapshots, ${deleted.archivedStreams} archive markers, ` +
73
+ `${deleted.upcasterDeadLetters + deleted.rebuildDeadLetters} dead letters.`);
74
+ }
75
+ out.log("");
76
+ }
77
+ export async function runEventsCli(argv, out, options) {
78
+ // The standalone bundle never runs the app boot that installs Temporal; event rows carry
79
+ // Temporal.Instant values.
80
+ await ensureTemporalPolyfill();
81
+ const [sub, ...rest] = argv;
82
+ if (sub !== "prune") {
83
+ if (sub !== undefined)
84
+ out.err(` Unknown subcommand: ${sub}`);
85
+ printHelp(sub === undefined ? out.log : out.err);
86
+ return sub === undefined ? 0 : 1;
87
+ }
88
+ const parsed = parseEventsPruneArgs(rest);
89
+ if (!parsed.ok) {
90
+ out.err(` ${parsed.error}`);
91
+ return 1;
92
+ }
93
+ const dbUrl = process.env["DATABASE_URL"];
94
+ if (!dbUrl) {
95
+ out.err(" DATABASE_URL not set.");
96
+ return 1;
97
+ }
98
+ const { db, close } = createDbConnection({
99
+ url: dbUrl,
100
+ rowLevelSecurity: options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE,
101
+ ...dbConnectionOptionsFromEnv(),
102
+ });
103
+ try {
104
+ const result = await removeAggregateTypes(db, createRegistry(options.features), {
105
+ types: parsed.types,
106
+ apply: parsed.apply,
107
+ });
108
+ report(out, result);
109
+ return 0;
110
+ }
111
+ catch (e) {
112
+ out.err("");
113
+ // Message only: a driver error's stack can carry connection details.
114
+ out.err(` ${e instanceof Error ? e.message : String(e)}`);
115
+ out.err("");
116
+ return 1;
117
+ }
118
+ finally {
119
+ await close();
120
+ }
121
+ }
@@ -2,10 +2,11 @@ import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
2
2
  import { isFileField } from "../engine/field-helpers.js";
3
3
  import { isAnonymousSessionUser } from "../engine/system-user.js";
4
4
  import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
5
- import { getFileRefExecutor, resolveAttachedField } from "./create-file-ref.js";
5
+ import { getFileRefExecutor } from "./create-file-ref.js";
6
6
  import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
7
7
  import { loadFileRefRows } from "./load-save-file-refs.js";
8
8
  import { validateFile } from "./types.js";
9
+ import { resolveAttachedField } from "./upload-field-resolution.js";
9
10
  export function fileRefIdsOf(value) {
10
11
  if (typeof value === "string")
11
12
  return isUuid(value) ? [value] : [];
@@ -13,4 +13,4 @@ export declare const bindReferencedFilesWrite: import("@cosmicdrift/kumiko-types
13
13
  bound: number;
14
14
  skipped: number;
15
15
  nextAfterId: string | null;
16
- }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
16
+ }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined, undefined>;
@@ -0,0 +1,10 @@
1
+ import type { Registry, SessionUser } from "../engine/types/index.ts";
2
+ import { type FileRef } from "./file-routes.ts";
3
+ export type CanReadFileRefContext = {
4
+ readonly registry: Registry;
5
+ readonly user: SessionUser;
6
+ readonly dispatch: {
7
+ readonly query: (qn: string, payload: unknown) => Promise<unknown>;
8
+ };
9
+ };
10
+ export declare function canCallerReadFileRef(ctx: CanReadFileRefContext, fileRef: FileRef): Promise<boolean>;
@@ -0,0 +1,12 @@
1
+ import { createDefaultGuard, DEFAULT_PRIVILEGED_ROLES } from "./default-file-guard.js";
2
+ import { readFilesRouteOptions } from "./file-routes.js";
3
+ import { collectRecordOnlyFileEntityTypes } from "./record-only-file-entity-types.js";
4
+ import { createRecordReadCheck } from "./record-read-check.js";
5
+ // Same decision as GET /files/:id: the app's accessGuard when configured, otherwise the
6
+ // default guard with the configured privileged roles and record-read check.
7
+ export async function canCallerReadFileRef(ctx, fileRef) {
8
+ const options = readFilesRouteOptions(ctx.registry);
9
+ const guard = options.accessGuard ??
10
+ createDefaultGuard(options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES, createRecordReadCheck(ctx.registry, (qn, payload) => ctx.dispatch.query(qn, payload)), options.unclaimedUploadReaders ?? [], collectRecordOnlyFileEntityTypes(ctx.registry));
11
+ return (await guard({ fileRef, user: ctx.user, operation: "read" })) === "allow";
12
+ }
@@ -21,6 +21,8 @@ export type CreateFileRefInput = {
21
21
  readonly fieldName: string;
22
22
  readonly entityId?: string;
23
23
  };
24
+ /** Byte limit replacing the configured default upload limit; a file field's own maxSize still applies first. */
25
+ readonly maxSize?: number;
24
26
  };
25
27
  export declare function filesUnavailableMessage(caller: string): string;
26
28
  export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
@@ -5,6 +5,9 @@ import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcc
5
5
  import { fileRefsTable } from "./file-ref-table.js";
6
6
  import { readFilesRouteOptions } from "./file-routes.js";
7
7
  import { assertSafeStorageKey } from "./types.js";
8
+ function defaultUploadLimit(registry, input) {
9
+ return input.maxSize ?? readFilesRouteOptions(registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
10
+ }
8
11
  // The handler context has no HTTP/job/seed marker to name, so the message states the actual
9
12
  // cause: the dispatcher's AppContext carries no file-provider resolver.
10
13
  export function filesUnavailableMessage(caller) {
@@ -25,7 +28,8 @@ export async function createFileRefFromBytes(ctx, input) {
25
28
  tenantDb: ctx.db,
26
29
  rowInOwnTransaction: runnerCanBeginTransaction(tenantDbRunner(ctx.db)),
27
30
  registry: ctx.registry,
28
- defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
31
+ attachTargets: "entities-and-read-models",
32
+ defaultMaxUploadSize: defaultUploadLimit(ctx.registry, input),
29
33
  writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
30
34
  deleteBytes: async (key) => {
31
35
  await files.ref(key).delete();
@@ -68,7 +72,8 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
68
72
  const outcome = await writeAcceptedFileBytes({
69
73
  user: ctx.user,
70
74
  registry: ctx.registry,
71
- defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
75
+ attachTargets: "entities-and-read-models",
76
+ defaultMaxUploadSize: defaultUploadLimit(ctx.registry, input),
72
77
  writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
73
78
  }, {
74
79
  fileName: input.fileName,
@@ -1,7 +1,7 @@
1
- import type { AnyFileFieldDef } from "@cosmicdrift/kumiko-types/fields";
2
1
  import type { TenantDb } from "../db/tenant-db.ts";
3
2
  import type { Registry, SessionUser } from "../engine/types/index.ts";
4
3
  import { type WriteFailure } from "../errors/index.ts";
4
+ import { type AttachTargets } from "./upload-field-resolution.ts";
5
5
  export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
6
6
  declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
7
7
  readonly id: string;
@@ -23,15 +23,6 @@ declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event
23
23
  readonly storageKey: string;
24
24
  }>;
25
25
  export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
26
- type AttachedFieldResolution = {
27
- readonly kind: "unattached";
28
- } | {
29
- readonly kind: "resolved";
30
- readonly fieldDef: AnyFileFieldDef;
31
- } | {
32
- readonly kind: "unresolvable";
33
- };
34
- export declare function resolveAttachedField(registry: Registry | undefined, entityType: string | undefined, fieldName: string | undefined): AttachedFieldResolution;
35
26
  export type StoreFileInput = {
36
27
  readonly fileName: string;
37
28
  readonly declaredMimeType: string;
@@ -48,7 +39,8 @@ export type StoreFileDeps = {
48
39
  readonly tenantDb: TenantDb;
49
40
  readonly rowInOwnTransaction?: boolean;
50
41
  readonly registry: Registry | undefined;
51
- readonly defaultMaxUploadSize: string;
42
+ readonly attachTargets?: AttachTargets;
43
+ readonly defaultMaxUploadSize: string | number;
52
44
  readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
53
45
  readonly deleteBytes: (storageKey: string) => Promise<void>;
54
46
  readonly isAttachTargetLocked?: (rowDb: TenantDb) => Promise<boolean>;
@@ -88,7 +80,7 @@ export type StoreFileOutcome = {
88
80
  readonly kind: "failed";
89
81
  readonly failure: WriteFailure;
90
82
  };
91
- export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
83
+ export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "attachTargets" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
92
84
  export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
93
85
  readonly fileRef: CreatedFileRef;
94
86
  } | {