@cosmicdrift/kumiko-framework 0.365.1 → 0.366.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 (103) hide show
  1. package/AGENTS.md +2 -2
  2. package/agents/examples/access-control.md +32 -30
  3. package/agents/examples/anonymous-access-multitenant.md +14 -16
  4. package/agents/examples/anonymous-access.md +36 -43
  5. package/agents/examples/auth-foundation-providers.md +1 -1
  6. package/agents/examples/basic-entity.md +1 -1
  7. package/agents/examples/boot-check.md +3 -3
  8. package/agents/examples/chat-channels.md +1 -1
  9. package/agents/examples/cross-feature-events.md +7 -7
  10. package/agents/examples/currencies-global.md +3 -3
  11. package/agents/examples/currencies-per-tenant.md +2 -2
  12. package/agents/examples/custom-fields-basic.md +4 -3
  13. package/agents/examples/custom-handlers.md +40 -44
  14. package/agents/examples/dashboard-stats.md +6 -6
  15. package/agents/examples/delivery-notifications.md +9 -9
  16. package/agents/examples/designer-demo.md +2 -1
  17. package/agents/examples/embedded-entity-form.md +13 -13
  18. package/agents/examples/embedded.md +1 -1
  19. package/agents/examples/encrypted-tenant-config.md +1 -1
  20. package/agents/examples/error-contract.md +25 -25
  21. package/agents/examples/event-sourcing.md +96 -76
  22. package/agents/examples/feature-toggles.md +29 -21
  23. package/agents/examples/files-post-processing.md +22 -26
  24. package/agents/examples/idempotency.md +17 -19
  25. package/agents/examples/inbound-mail-triage.md +1 -1
  26. package/agents/examples/lane-routing.md +10 -12
  27. package/agents/examples/legal-pages.md +1 -1
  28. package/agents/examples/lifecycle-hooks.md +59 -39
  29. package/agents/examples/managed-config.md +9 -9
  30. package/agents/examples/managed-pages.md +1 -1
  31. package/agents/examples/notes-history-basic.md +3 -3
  32. package/agents/examples/pdf-audit-export.md +7 -7
  33. package/agents/examples/pipeline-basics.md +8 -8
  34. package/agents/examples/public-share-token.md +1 -1
  35. package/agents/examples/rate-limiting.md +7 -9
  36. package/agents/examples/record-detail-layout.md +26 -20
  37. package/agents/examples/reference-data.md +1 -1
  38. package/agents/examples/relations.md +2 -2
  39. package/agents/examples/screens-nav.md +2 -2
  40. package/agents/examples/search.md +17 -19
  41. package/agents/examples/security-baseline.md +1 -1
  42. package/agents/examples/state-machine.md +1 -1
  43. package/agents/examples/tags-basic.md +3 -3
  44. package/agents/examples/tenant-settings.md +2 -2
  45. package/agents/examples/timezones.md +12 -12
  46. package/agents/examples/user-data-rights.md +3 -3
  47. package/agents/examples/user-profile.md +1 -1
  48. package/agents/examples/webhook-step.md +1 -1
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/widgets.md +5 -0
  51. package/dist/api/server.js +12 -4
  52. package/dist/bun-db/query.js +16 -1
  53. package/dist/db/entity-table-meta.js +4 -4
  54. package/dist/engine/boot-validator/api-ext.js +2 -2
  55. package/dist/engine/boot-validator/config-deps.js +1 -1
  56. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  57. package/dist/engine/boot-validator/screens.js +1 -1
  58. package/dist/engine/build-app-schema.js +3 -6
  59. package/dist/engine/define-feature.js +5 -5
  60. package/dist/engine/entity-handlers.d.ts +2 -2
  61. package/dist/engine/entity-handlers.js +5 -5
  62. package/dist/engine/factories.d.ts +11 -11
  63. package/dist/engine/factories.js +11 -11
  64. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  65. package/dist/engine/feature-ast/render.js +4 -7
  66. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  67. package/dist/engine/feature-config-events-jobs.js +3 -0
  68. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  69. package/dist/engine/feature-entity-handlers.js +77 -100
  70. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  71. package/dist/engine/feature-ui-extensions.js +22 -14
  72. package/dist/engine/handler-field-references.d.ts +25 -0
  73. package/dist/engine/handler-field-references.js +80 -6
  74. package/dist/engine/index.d.ts +3 -3
  75. package/dist/engine/index.js +2 -2
  76. package/dist/engine/object-form.d.ts +2 -0
  77. package/dist/engine/object-form.js +13 -10
  78. package/dist/engine/registry-state.js +2 -2
  79. package/dist/engine/registry-validate.d.ts +1 -0
  80. package/dist/engine/registry-validate.js +21 -2
  81. package/dist/engine/registry.js +2 -1
  82. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  83. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  84. package/dist/engine/types/index.d.ts +1 -1
  85. package/dist/files/feature.js +1 -1
  86. package/dist/jobs/job-runner.js +16 -2
  87. package/dist/pipeline/dispatch-query.js +1 -1
  88. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  89. package/dist/pipeline/dispatch-shared.js +3 -6
  90. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  91. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  92. package/dist/testing/index.d.ts +1 -0
  93. package/dist/testing/index.js +1 -0
  94. package/dist/testing/manual-clock.d.ts +14 -0
  95. package/dist/testing/manual-clock.js +12 -0
  96. package/dist/time/index.d.ts +1 -1
  97. package/dist/time/tz-context.d.ts +1 -1
  98. package/dist/time/tz-context.js +9 -4
  99. package/dist/upgrade-cli.js +13 -2
  100. package/package.json +4 -4
  101. package/src/changes.json +40 -0
  102. package/src/scripts/codemod/README.md +23 -0
  103. package/src/scripts/codemod/object-form.ts +709 -0
@@ -2,3 +2,5 @@ export declare function splitNamedDefinition<T extends {
2
2
  readonly name: string;
3
3
  }>(definition: T): [string, Omit<T, "name">];
4
4
  export declare function unwrapArrayForm<K extends string>(args: readonly [Record<K, readonly string[]>] | readonly string[], key: K): readonly string[];
5
+ export declare function isObjectArgument(value: unknown): boolean;
6
+ export declare function throwPositionalFormRemoved(featureName: string, method: string): never;
@@ -1,17 +1,12 @@
1
- // Shared helper for registrar methods that accept an Object-Form call
2
- // (`r.entity({ name: "item", ... })`) alongside the classic positional form
3
- // (`r.entity("item", { ... })`). Object-Form is the shape the feature-ast
4
- // renderer emits for Designer/AI-generated code — a single object argument
5
- // with named fields is easier to generate correctly than positional args
6
- // whose count/order vary per method.
1
+ // Shared helpers for registrar methods that take a single object argument
2
+ // (`r.entity({ name: "item", ... })`). The positional overloads are gone; the
3
+ // runtime check below turns a leftover positional call into a pointer at the codemod.
7
4
  export function splitNamedDefinition(definition) {
8
5
  const { name, ...rest } = definition;
9
6
  return [name, rest];
10
7
  }
11
- // Shared helper for registrar methods that accept either variadic strings
12
- // (hand-written call sites) or a single Object-Form object wrapping the
13
- // same string array under `key` (the feature-ast renderer's canonical
14
- // shape for Designer/AI-generated code).
8
+ // `optionalRequires` and `readConfig` still accept variadic strings next to a
9
+ // single object wrapping the same string array under `key`.
15
10
  export function unwrapArrayForm(args, key) {
16
11
  const [first] = args;
17
12
  if (typeof first === "object" && first !== null && key in first) {
@@ -19,3 +14,11 @@ export function unwrapArrayForm(args, key) {
19
14
  }
20
15
  return args;
21
16
  }
17
+ export function isObjectArgument(value) {
18
+ return typeof value === "object" && value !== null;
19
+ }
20
+ // Removed in the first release after 2026-10-29; until then a positional call fails with
21
+ // a pointer at the codemod instead of an obscure "undefined is not a function".
22
+ export function throwPositionalFormRemoved(featureName, method) {
23
+ throw new Error(`[Feature ${featureName}] positional r.${method}(…) removed in 0.366, run \`bun kumiko-upgrade --apply\``);
24
+ }
@@ -12,7 +12,7 @@ const IMPLICIT_PROJECTION_SUFFIX = "-entity";
12
12
  // durch implicit-projection-equivalence.integration.ts).
13
13
  export function buildImplicitProjection(featureName, entityName, entity, qualify, backingTable, extensions = []) {
14
14
  const name = qualify(featureName, "projection", `${entityName}${IMPLICIT_PROJECTION_SUFFIX}`);
15
- // Backing table (r.entity(name, def, { table })) is the one physical table
15
+ // Backing table (r.entity({ name, ...def, backingTable })) is the one physical table
16
16
  // object shared by executor-writes, rebuild-replay, test-push and
17
17
  // collectTableMetas — restoring the #255 invariant (test-push == generate).
18
18
  // Validated as a superset of the field-derived columns so a field/table
@@ -107,7 +107,7 @@ function collectExtensionSources(projectionName, entityName, extensions) {
107
107
  function resolveBackingTable(entityName, entity, backingTable) {
108
108
  const tableMeta = asEntityTableMeta(backingTable);
109
109
  if (!tableMeta) {
110
- throw new Error(`r.entity("${entityName}", …, { table }): the backing table carries no ` +
110
+ throw new Error(`r.entity({ name: "${entityName}", …, backingTable }): the backing table carries no ` +
111
111
  "EntityTableMeta — build it via table() / buildEntityTable.");
112
112
  }
113
113
  assertBackingTableSuperset(entityName, deriveEntityTableMeta(entityName, entity), tableMeta);
@@ -19,6 +19,7 @@ export declare function buildEventUpcasterChains(state: RegistryState, features:
19
19
  export declare function validateProjectionApplyKeys(state: RegistryState): void;
20
20
  export declare function validateRequiredFeatures(state: RegistryState, features: readonly FeatureDefinition[]): void;
21
21
  export declare function validateNotificationRecordMappings(state: RegistryState): void;
22
+ export declare function validateNotificationRenamedFrom(state: RegistryState): void;
22
23
  export declare function resolveNotificationTriggersAndRegisterHooks(state: RegistryState): void;
23
24
  export declare function validateLifecycleHookTargets(state: RegistryState): void;
24
25
  export declare function validateEntityHookTargets(state: RegistryState, features: readonly FeatureDefinition[]): void;
@@ -462,7 +462,7 @@ export function validateProjectionApplyKeys(state) {
462
462
  const validEventTypes = new Set();
463
463
  // Two source-modes are legal:
464
464
  //
465
- // (a) Registered entity (r.entity(src, ...)) — the "normal" case:
465
+ // (a) Registered entity (r.entity({ name: src, ... })) — the "normal" case:
466
466
  // auto-lifecycle events `<src>.created/.updated/.deleted/.restored`
467
467
  // fire when the event-store-executor writes, and any domain-event
468
468
  // (r.defineEvent) appended onto an aggregate of that type is
@@ -497,7 +497,7 @@ export function validateProjectionApplyKeys(state) {
497
497
  throw new Error(`Projection "${projName}" declares source(s) [${unregistered.join(", ")}] that are not registered entities, ` +
498
498
  `and has no domain-event apply-keys. This is either a typo or a missing r.defineEvent registration. ` +
499
499
  `Events-only projections need at least one apply-key from r.defineEvent; ` +
500
- `CRUD-style projections need r.entity("${unregistered[0]}", ...).`);
500
+ `CRUD-style projections need r.entity({ name: "${unregistered[0]}", ... }).`);
501
501
  }
502
502
  }
503
503
  for (const applyKey of Object.keys(projDef.apply)) {
@@ -547,6 +547,25 @@ export function validateNotificationRecordMappings(state) {
547
547
  }
548
548
  }
549
549
  }
550
+ export function validateNotificationRenamedFrom(state) {
551
+ const ownerByAlias = new Map();
552
+ for (const [qualifiedName, notifDef] of state.notificationMap) {
553
+ for (const alias of notifDef.renamedFrom ?? []) {
554
+ const where = `Notification "${qualifiedName}" renamedFrom "${alias}"`;
555
+ if (alias === "*") {
556
+ throw new Error(`${where}: "*" is the preference wildcard, not a notification name`);
557
+ }
558
+ if (state.notificationMap.has(alias)) {
559
+ throw new Error(`${where} is a current notification name; remove it from renamedFrom or rename the notification`);
560
+ }
561
+ const otherOwner = ownerByAlias.get(alias);
562
+ if (otherOwner !== undefined && otherOwner !== qualifiedName) {
563
+ throw new Error(`${where} is also declared by notification "${otherOwner}"; an alias can have only one owner, remove it from one of them`);
564
+ }
565
+ ownerByAlias.set(alias, qualifiedName);
566
+ }
567
+ }
568
+ }
550
569
  export function resolveNotificationTriggersAndRegisterHooks(state) {
551
570
  // Resolve notification triggers and register postSave hooks
552
571
  // Done after all features are registered so cross-feature triggers work
@@ -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, validateMaxPerTenant, 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, validateNotificationRenamedFrom, 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).
@@ -42,6 +42,7 @@ export function createRegistry(rawFeatures) {
42
42
  validateRequiredFeatures(state, features);
43
43
  resolveNotificationTriggersAndRegisterHooks(state);
44
44
  validateNotificationRecordMappings(state);
45
+ validateNotificationRenamedFrom(state);
45
46
  validateLifecycleHookTargets(state);
46
47
  validateEntityHookTargets(state, features);
47
48
  resolveJobTriggers(state);
@@ -0,0 +1,6 @@
1
+ import type { AppSchema } from "../ui-types/app-schema.js";
2
+ import type { EffectiveFeaturesResolver } from "./tier-resolver-extension.js";
3
+ import type { Registry } from "./types/feature.js";
4
+ import type { TenantId } from "./types/identifiers.js";
5
+ export declare function resolveDisabledFeatureNames(registry: Registry, effectiveFeatures: EffectiveFeaturesResolver, tenantId: TenantId): Promise<readonly string[]>;
6
+ export declare function stripDisabledFeaturesFromAppSchema(schema: AppSchema, registry: Registry, disabledFeatureNames: readonly string[]): AppSchema;
@@ -0,0 +1,89 @@
1
+ // Tenant-level counterpart to projectAppSchemaForRoles: removes the UI surface
2
+ // (screens, navs, content collections, workspaces) of features the tenant has
3
+ // switched off via r.toggleable(). Entities and translations stay — enabled
4
+ // features may still reference them. Run BEFORE the role projection: it keeps
5
+ // only targets that exist in the schema, so once the disabled screens/navs are
6
+ // gone it also drops every action, redirect, nav and workspace member that
7
+ // pointed at them. The one reference it does not resolve is a nav `parent`
8
+ // pointing at a missing nav; that is pruned here.
9
+ import { isToggleableFeature } from "./effective-features.js";
10
+ import { isValidQn, qualifyEntityName } from "./qualified-name.js";
11
+ // Sorted, so the result doubles as a stable cache key. The trial gate is
12
+ // consulted like the dispatcher's feature gate does, but only for toggleable
13
+ // features: a trial can only unlock tier (toggleable) features, so a
14
+ // cascade-disabled always-on feature needs no gate call.
15
+ export async function resolveDisabledFeatureNames(registry, effectiveFeatures, tenantId) {
16
+ const enabled = effectiveFeatures(tenantId);
17
+ const disabled = [];
18
+ for (const [featureName, feature] of registry.features) {
19
+ if (enabled.has(featureName))
20
+ continue;
21
+ if (isToggleableFeature(feature) &&
22
+ (await effectiveFeatures.trialGate?.(tenantId, featureName))) {
23
+ continue;
24
+ }
25
+ disabled.push(featureName);
26
+ }
27
+ return disabled.sort();
28
+ }
29
+ function navParentQn(featureName, parent) {
30
+ return isValidQn(parent) ? parent : qualifyEntityName(featureName, "nav", parent);
31
+ }
32
+ // Navs of enabled features whose parent chain ends in a removed nav.
33
+ function collectOrphanedNavQns(features, removedNavQns) {
34
+ const orphaned = new Set();
35
+ let changed = true;
36
+ while (changed) {
37
+ changed = false;
38
+ for (const feature of features) {
39
+ for (const nav of feature.navs ?? []) {
40
+ const qn = qualifyEntityName(feature.featureName, "nav", nav.id);
41
+ if (removedNavQns.has(qn) || nav.parent === undefined)
42
+ continue;
43
+ if (removedNavQns.has(navParentQn(feature.featureName, nav.parent))) {
44
+ removedNavQns.add(qn);
45
+ orphaned.add(qn);
46
+ changed = true;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return orphaned;
52
+ }
53
+ export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFeatureNames) {
54
+ if (disabledFeatureNames.length === 0)
55
+ return schema;
56
+ const disabled = new Set(disabledFeatureNames);
57
+ const removedNavQns = new Set();
58
+ for (const feature of schema.features) {
59
+ if (!disabled.has(feature.featureName))
60
+ continue;
61
+ for (const nav of feature.navs ?? []) {
62
+ removedNavQns.add(qualifyEntityName(feature.featureName, "nav", nav.id));
63
+ }
64
+ }
65
+ const orphanedNavQns = collectOrphanedNavQns(schema.features, removedNavQns);
66
+ const features = schema.features.map((feature) => {
67
+ if (disabled.has(feature.featureName)) {
68
+ const { navs: _navs, contentCollections: _collections, workspaces: _ws, ...rest } = feature;
69
+ return { ...rest, screens: [] };
70
+ }
71
+ if (orphanedNavQns.size === 0 || feature.navs === undefined)
72
+ return feature;
73
+ const navs = feature.navs.filter((nav) => !orphanedNavQns.has(qualifyEntityName(feature.featureName, "nav", nav.id)));
74
+ if (navs.length === feature.navs.length)
75
+ return feature;
76
+ const { navs: _navs, ...rest } = feature;
77
+ return navs.length > 0 ? { ...rest, navs } : rest;
78
+ });
79
+ // Workspace definitions carry the short id the author declared; the owning
80
+ // feature is only known to the registry.
81
+ const disabledWorkspaceIds = new Set(disabledFeatureNames.flatMap((featureName) => Object.keys(registry.features.get(featureName)?.workspaces ?? {})));
82
+ const workspaces = schema.workspaces?.filter((ws) => !disabledWorkspaceIds.has(ws.definition.id));
83
+ const { workspaces: _original, ...restSchema } = schema;
84
+ return {
85
+ ...restSchema,
86
+ features,
87
+ ...(workspaces !== undefined && workspaces.length > 0 && { workspaces }),
88
+ };
89
+ }
@@ -4,7 +4,7 @@ export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefin
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";
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
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";
7
+ export type { BootCheckContext, BootCheckFn, EntityObjectDefinition, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, HookDefinition, 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, EntityEventMigration, 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
10
  export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, 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, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
@@ -22,7 +22,7 @@ export function createFilesFeature(options = {}) {
22
22
  category: "storage",
23
23
  recommended: false,
24
24
  });
25
- r.entity(FILE_REF_ENTITY_NAME, fileRefEntity);
25
+ r.entity({ name: FILE_REF_ENTITY_NAME, ...fileRefEntity });
26
26
  r.writeHandler(bindReferencedFilesWrite);
27
27
  return { routeOptions: options };
28
28
  });
@@ -1,3 +1,4 @@
1
+ /// <reference types="temporal-polyfill/global" preserve="true" />
1
2
  import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
2
3
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
4
  import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
@@ -16,8 +17,10 @@ import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeH
16
17
  import { createDistributedLock } from "../pipeline/distributed-lock.js";
17
18
  import { RedisKeys } from "../pipeline/redis-keys.js";
18
19
  import { createGatedIdentitySwitch } from "../pipeline/system-identity-switch.js";
20
+ import { TENANT_TIMEZONE_CONFIG_KEY } from "../pipeline/tenant-timezone-config-key.js";
19
21
  import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
20
22
  import { bridgeStub } from "../testing/handler-context.js";
23
+ import { createTzContext, isValidIanaTimeZone } from "../time/index.js";
21
24
  import { cronRepeatOptions } from "./cron-repeat-options.js";
22
25
  import { resolveMaxPerTenant } from "./max-per-tenant.js";
23
26
  // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
@@ -201,6 +204,14 @@ function jobSubjectKey(jobName, payload, fields, declaration) {
201
204
  }
202
205
  return serializeJobSubject(subject);
203
206
  }
207
+ async function resolveJobTz(config, context) {
208
+ const tenantTz = config ? await config(TENANT_TIMEZONE_CONFIG_KEY) : undefined;
209
+ return createTzContext({
210
+ ...(context.geoTzProvider !== undefined && { geoTz: context.geoTzProvider }),
211
+ ...(context.clock !== undefined && { clock: context.clock }),
212
+ tenant: typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC",
213
+ });
214
+ }
204
215
  // Jobs that never touch ctx.db run fine on a runner built without one; a job
205
216
  // that does gets a named error instead of a bare TypeError on undefined.
206
217
  function missingDbProxy(jobName) {
@@ -984,7 +995,7 @@ export function createJobRunner(options) {
984
995
  return dispatchWriteRef.write(user, qn, payload, jobOrigin);
985
996
  },
986
997
  }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
987
- const jobContext = {
998
+ const jobContextWithoutTz = {
988
999
  ...context,
989
1000
  db: jobDb ?? missingDbProxy(jobName),
990
1001
  files,
@@ -1060,7 +1071,10 @@ export function createJobRunner(options) {
1060
1071
  handler: jobName,
1061
1072
  feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
1062
1073
  writeOrigin: jobOrigin,
1063
- }, () => jobDef.handler(payload, jobContext));
1074
+ }, async () => jobDef.handler(payload, {
1075
+ ...jobContextWithoutTz,
1076
+ tz: await resolveJobTz(config, context),
1077
+ }));
1064
1078
  // Stamped before the observer hook: a throwing onJobComplete must not
1065
1079
  // make a run that actually succeeded look dead to the liveness alert.
1066
1080
  if (context.meter) {
@@ -66,7 +66,7 @@ async function runQueryHandler(ctx, type, handler, payload, includeDeleted, user
66
66
  // below only knows the entity's stammfields).
67
67
  //
68
68
  // Two firing-pfade kombiniert in dieser Reihenfolge:
69
- // 1. Handler-keyed hooks via r.hook("postQuery", "ns:query:list", fn)
69
+ // 1. Handler-keyed hooks via r.hook({ type: "postQuery", target: "ns:query:list", handler })
70
70
  // — feuern nur für genau diesen handler
71
71
  // 2. Entity-keyed hooks via r.entityHook("postQuery", "property", fn)
72
72
  // — feuern für ALLE query-handlers des entity
@@ -12,9 +12,10 @@ import { type AfterCommitHook } from "./dispatcher-utils.js";
12
12
  import type { IdempotencyGuard } from "./idempotency.js";
13
13
  import type { LifecycleHooks } from "./lifecycle-pipeline.js";
14
14
  import type { TenantTimezoneCache } from "./tenant-timezone-cache.js";
15
+ import { TENANT_TIMEZONE_CONFIG_KEY } from "./tenant-timezone-config-key.js";
15
16
  import { type WriteOrigin } from "./write-origin.js";
16
17
  export type { WriteOrigin } from "./write-origin.js";
17
- export declare const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
18
+ export { TENANT_TIMEZONE_CONFIG_KEY };
18
19
  export declare const CONFIG_WRITE_SET_TYPE = "config:write:set";
19
20
  export declare const CONFIG_WRITE_RESET_TYPE = "config:write:reset";
20
21
  export type BatchCommand = {
@@ -29,13 +29,9 @@ import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } f
29
29
  import { createMemberReaderFn } from "./member-reader.js";
30
30
  import { createDeferringNotify } from "./notify-scope.js";
31
31
  import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
32
+ import { TENANT_TIMEZONE_CONFIG_KEY } from "./tenant-timezone-config-key.js";
32
33
  import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
33
- // Framework/pipeline stays bundled-features-free, so this can't import the
34
- // `tenant` feature — the literal below IS the coupling to its `timezone`
35
- // config key. Renaming that key (or the "tenant" feature name) must update
36
- // this constant too; tenant-timezone-boot.integration.test.ts boots the real
37
- // createTenantFeature() and would catch a drift.
38
- export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
34
+ export { TENANT_TIMEZONE_CONFIG_KEY };
39
35
  // Same bundled-features-free constraint as above — these name the config
40
36
  // feature's write handlers so dispatch-write.ts can invalidate
41
37
  // TENANT_TIMEZONE_CONFIG_KEY's cache entry on a successful write without
@@ -826,6 +822,7 @@ readOnlyForTenant = false) {
826
822
  const safeUserTz = user.timezone !== undefined && isValidIanaTimeZone(user.timezone) ? user.timezone : undefined;
827
823
  const tz = createTzContext({
828
824
  ...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
825
+ ...(context.clock !== undefined ? { clock: context.clock } : {}),
829
826
  tenant: safeTenantTz,
830
827
  ...(safeUserTz !== undefined && { user: safeUserTz }),
831
828
  });
@@ -0,0 +1 @@
1
+ export declare const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
@@ -0,0 +1,6 @@
1
+ // Framework/pipeline stays bundled-features-free, so this can't import the
2
+ // `tenant` feature — the literal below IS the coupling to its `timezone`
3
+ // config key. Renaming that key (or the "tenant" feature name) must update
4
+ // this constant too; tenant-timezone-boot.integration.test.ts boots the real
5
+ // createTenantFeature() and would catch a drift.
6
+ export const TENANT_TIMEZONE_CONFIG_KEY = "tenant:config:timezone";
@@ -13,6 +13,7 @@ export { describeFileProviderContract } from "./file-provider-contract.js";
13
13
  export { bridgeStub } from "./handler-context.js";
14
14
  export { getSetCookieRaw, getSetCookies, getSetCookieValue, type ParsedSetCookie, } from "./http-cookies.js";
15
15
  export { createLateBoundHolder, type LateBoundHolder } from "./late-bound.js";
16
+ export { createManualClock, type ManualClock } from "./manual-clock.js";
16
17
  export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper.js";
17
18
  export { createMutableMasterKeyProvider, createTestEnvelopeCipher, createTestMasterKeyProvider, type MutableMasterKeyProvider, } from "./mutable-master-key-provider.js";
18
19
  export { createRecordingProvider, type RecordingProvider, } from "./observability-recorder.js";
@@ -21,6 +21,7 @@ export { describeFileProviderContract } from "./file-provider-contract.js";
21
21
  export { bridgeStub } from "./handler-context.js";
22
22
  export { getSetCookieRaw, getSetCookies, getSetCookieValue, } from "./http-cookies.js";
23
23
  export { createLateBoundHolder } from "./late-bound.js";
24
+ export { createManualClock } from "./manual-clock.js";
24
25
  export { buildMultipartBody, patchFileInstanceofForBunTest } from "./multipart-helper.js";
25
26
  export { createMutableMasterKeyProvider, createTestEnvelopeCipher, createTestMasterKeyProvider, } from "./mutable-master-key-provider.js";
26
27
  export { createRecordingProvider, } from "./observability-recorder.js";
@@ -0,0 +1,14 @@
1
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
3
+ type ClockStep = {
4
+ readonly hours?: number;
5
+ readonly minutes?: number;
6
+ readonly seconds?: number;
7
+ readonly milliseconds?: number;
8
+ };
9
+ export type ManualClock = Clock & {
10
+ set(instant: Temporal.Instant): void;
11
+ advance(step: ClockStep): void;
12
+ };
13
+ export declare function createManualClock(start: Temporal.Instant): ManualClock;
14
+ export {};
@@ -0,0 +1,12 @@
1
+ export function createManualClock(start) {
2
+ let current = start;
3
+ return {
4
+ now: () => current,
5
+ set: (instant) => {
6
+ current = instant;
7
+ },
8
+ advance: (step) => {
9
+ current = current.add(step);
10
+ },
11
+ };
12
+ }
@@ -3,4 +3,4 @@ export type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz.js";
3
3
  export { isValidIanaTimeZone } from "./iana.js";
4
4
  export { instantToLegacyDate, legacyDateToInstant } from "./legacy-date.js";
5
5
  export { ensureTemporalPolyfill, getTemporal } from "./polyfill.js";
6
- export { createTzContext, createTzContextAsync, type LocatedTimestampJson, type TzContext, type TzContextOptions, } from "./tz-context.js";
6
+ export { type Clock, createTzContext, createTzContextAsync, type LocatedTimestampJson, type TzContext, type TzContextOptions, } from "./tz-context.js";
@@ -1,5 +1,5 @@
1
1
  import type { TzContext, TzContextOptions } from "@cosmicdrift/kumiko-types/tz-context";
2
- export type { LocatedTimestampJson, TzContext, TzContextOptions, } from "@cosmicdrift/kumiko-types/tz-context";
2
+ export type { Clock, LocatedTimestampJson, TzContext, TzContextOptions, } from "@cosmicdrift/kumiko-types/tz-context";
3
3
  /**
4
4
  * Factory: creates a TzContext for the current request.
5
5
  * Uses the kumiko-types Temporal module export (not globalThis.Temporal) so
@@ -19,6 +19,9 @@
19
19
  // live in @cosmicdrift/kumiko-types/tz-context — only the factories are here.
20
20
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
21
21
  import { ensureTemporalPolyfill } from "./polyfill.js";
22
+ const SYSTEM_CLOCK = {
23
+ now: () => Temporal.Now.instant(), // @wrapper-known semantic-alias
24
+ };
22
25
  /**
23
26
  * Factory: creates a TzContext for the current request.
24
27
  * Uses the kumiko-types Temporal module export (not globalThis.Temporal) so
@@ -28,14 +31,16 @@ export function createTzContext(options = {}) {
28
31
  const tenant = options.tenant ?? "UTC";
29
32
  const user = options.user ?? tenant;
30
33
  const geoTz = options.geoTz;
34
+ const clock = options.clock ?? SYSTEM_CLOCK;
35
+ const nowIn = (tz) => clock.now().toZonedDateTimeISO(tz);
31
36
  return {
32
37
  tenant,
33
38
  user,
34
- now: () => Temporal.Now.instant(), // @wrapper-known semantic-alias
35
- nowIn: (tz) => Temporal.Now.zonedDateTimeISO(tz), // @wrapper-known semantic-alias
36
- today: (tz) => Temporal.Now.plainDateISO(tz), // @wrapper-known semantic-alias
39
+ now: () => clock.now(),
40
+ nowIn,
41
+ today: (tz) => nowIn(tz).toPlainDate(),
37
42
  todayRange: (tz) => {
38
- const today = Temporal.Now.plainDateISO(tz);
43
+ const today = nowIn(tz).toPlainDate();
39
44
  const startZdt = today.toZonedDateTime({ timeZone: tz });
40
45
  const endZdt = today.add({ days: 1 }).toZonedDateTime({ timeZone: tz });
41
46
  return { start: startZdt.toInstant(), end: endZdt.toInstant() };
@@ -297,6 +297,12 @@ export function manualEntryId(version, title) {
297
297
  const hash = createHash("sha256").update(title).digest("hex").slice(0, MANUAL_ID_HASH_LENGTH);
298
298
  return `${version}:${hash}`;
299
299
  }
300
+ // Breaking entries that need hand work carry the id `kumiko-upgrade --resolve` takes.
301
+ function manualIdOf(entry) {
302
+ return entry.type === "breaking" && needsManualMigration(entry)
303
+ ? manualEntryId(entry.version, entry.title)
304
+ : undefined;
305
+ }
300
306
  function toMarkerManual(entry) {
301
307
  return {
302
308
  id: manualEntryId(entry.version, entry.title),
@@ -739,7 +745,11 @@ export async function runUpgradeCli(argv, cwd, out, options) {
739
745
  }
740
746
  if (jsonMode) {
741
747
  const { pendingManual } = carryOf(markerRead);
742
- out.log(JSON.stringify({ currentVersion, installedVersion, pending, pendingManual }, null, 2));
748
+ const pendingWithManualId = pending.map((entry) => {
749
+ const manualId = manualIdOf(entry);
750
+ return manualId === undefined ? entry : { ...entry, manualId };
751
+ });
752
+ out.log(JSON.stringify({ currentVersion, installedVersion, pending: pendingWithManualId, pendingManual }, null, 2));
743
753
  return 0;
744
754
  }
745
755
  const breaking = pending.filter((e) => e.type === "breaking");
@@ -759,7 +769,8 @@ export async function runUpgradeCli(argv, cwd, out, options) {
759
769
  out.log(` ⚠ BREAKING (${breaking.length})`);
760
770
  out.log("");
761
771
  for (const e of breaking) {
762
- out.log(` ${e.version} · ${e.title}`);
772
+ const manualId = manualIdOf(e);
773
+ out.log(` ${e.version} · ${e.title}${manualId === undefined ? "" : ` [${manualId}]`}`);
763
774
  if (verbose && e.detail) {
764
775
  out.log(` ${e.detail}`);
765
776
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.365.1",
3
+ "version": "0.366.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -210,8 +210,8 @@
210
210
  "./package.json": "./package.json"
211
211
  },
212
212
  "dependencies": {
213
- "@cosmicdrift/kumiko-http": "0.365.1",
214
- "@cosmicdrift/kumiko-types": "0.365.1",
213
+ "@cosmicdrift/kumiko-http": "0.366.0",
214
+ "@cosmicdrift/kumiko-types": "0.366.0",
215
215
  "bullmq": "^5.76.7",
216
216
  "bun-types": "^1.3.13",
217
217
  "hono": "^4.13.1",
@@ -227,7 +227,7 @@
227
227
  "zod": "^4.4.3"
228
228
  },
229
229
  "devDependencies": {
230
- "@cosmicdrift/kumiko-dispatcher-live": "0.365.1",
230
+ "@cosmicdrift/kumiko-dispatcher-live": "0.366.0",
231
231
  "bun-types": "^1.3.13",
232
232
  "pino-pretty": "^13.1.3"
233
233
  },
package/src/changes.json CHANGED
@@ -1,4 +1,44 @@
1
1
  [
2
+ {
3
+ "version": "0.366.0",
4
+ "type": "breaking",
5
+ "title": "One injectable Clock for ctx.tz in handlers and jobs",
6
+ "detail": "New type Clock and TzContextOptions.clock. AppContext.clock feeds ctx.tz.now() in handlers, and JobContext.tz gives jobs the same ctx.tz API (tenant timezone from tenant config, read once per job run, UTC fallback). The testing helper createManualClock(start) returns a clock with set and advance. The tenant-lifecycle job now reads time through ctx.tz.now().",
7
+ "migration": "Code that builds a JobContext literal itself (boot seeds, test doubles) adds `tz: createTzContext()`."
8
+ },
9
+ {
10
+ "version": "0.366.0",
11
+ "type": "improvement",
12
+ "title": "Handler input references work on nested fields and record keys",
13
+ "detail": "The references meta on handler input schemas is now found on fields inside arrays, nested objects and records, and on record keys. The new readHandlerFieldReferences and handlerFieldReferences return each hit with a path such as \"channels[].channelKey\" or \"labels{key}\" plus typed path segments. Boot validation checks nested references too and names the path in its error message. readHandlerFieldReference and handlerFieldReference behave as before."
14
+ },
15
+ {
16
+ "version": "0.366.0",
17
+ "type": "breaking",
18
+ "title": "Registrar methods take a single object, positional calls throw at registration",
19
+ "detail": "r.writeHandler, r.queryHandler, r.entity, r.requires and r.hook now take one object. The positional overloads are gone from the types and throw when the feature is defined, with a message that points at the codemod. The throw is removed in the first release after 2026-10-29.\n\nwriteHandler (queryHandler is the same shape):\n before: r.writeHandler(\"order:archive\", schema, handler, { access });\n after: r.writeHandler({ name: \"order:archive\", schema, handler, access });\n\nhook (type picks the handler shape, target is a name, a list or { allOf }):\n before: r.hook(\"postSave\", \"order\", handler, { escapeHatch });\n after: r.hook({ type: \"postSave\", target: \"order\", handler, escapeHatch });\n\nentity and requires:\n before: r.entity(\"order\", definition, { table: ordersTable }); r.requires(\"auth\", \"tenant\");\n after: r.entity({ name: \"order\", ...definition, backingTable: ordersTable }); r.requires({ features: [\"auth\", \"tenant\"] });\n\nregisterEntityCrud is no longer exported from @cosmicdrift/kumiko-framework/engine; call r.crud(name, entity, options) instead. The entity option { table } is now backingTable on the r.entity object and lands in the feature's entityTables, not in its entities. HookDefinition and EntityObjectDefinition are exported from @cosmicdrift/kumiko-types. Hook definitions are checked per type: validation takes no escapeHatch, phase only exists on postSave and postDelete, and { allOf } is not accepted on preSave.",
20
+ "migration": "Run `bun kumiko-upgrade --apply`. It rewrites every positional r.writeHandler, r.queryHandler, r.entity, r.requires, r.hook and registerEntityCrud call to the object form. Calls it cannot resolve statically (a spread argument, a registrar that is not typed as FeatureRegistrar) are listed as manual work with file and line; rewrite those by hand.",
21
+ "codemod": "scripts/codemod/object-form.ts",
22
+ "manualAfterCodemod": true
23
+ },
24
+ {
25
+ "version": "0.366.0",
26
+ "type": "improvement",
27
+ "title": "The app schema no longer lists screens, navs and workspaces of features a tenant has switched off",
28
+ "detail": "A feature declared with r.toggleable() that is off for the caller's tenant (tier engine or feature toggles) used to stay in the nav while all its handlers answered 403 feature_disabled. GET /api/schema now drops its screens, navs, content collections and workspaces for that tenant, and every action, redirect, nav parent and workspace member that pointed at them is removed too. Entities and translations stay. The response is cached per role set and per set of disabled features, so tenants with the same set share one ETag. Without an effectiveFeatures resolver nothing changes."
29
+ },
30
+ {
31
+ "version": "0.366.0",
32
+ "type": "improvement",
33
+ "title": "selectMany supports column projection via SelectOptions.fields",
34
+ "detail": "SelectOptions gains fields, a list of entity field names. selectMany then selects only those columns instead of all of them. An empty list or a name that is not a column of the table throws."
35
+ },
36
+ {
37
+ "version": "0.366.0",
38
+ "type": "improvement",
39
+ "title": "kumiko-upgrade names each pending manual step by an id",
40
+ "detail": "Every pending manual step of kumiko-upgrade now has a stable id, derived from the release version and the step title. The text output shows it in brackets and the JSON output carries it as manualId, so a script or an agent can tell steps apart and record which ones it has done."
41
+ },
2
42
  {
3
43
  "version": "0.365.1",
4
44
  "type": "fix",
@@ -34,3 +34,26 @@ at — fix those by hand. The report also lists every field that newly
34
34
  gains `lookupable` (searchable-only fields resolving to `find: "fuzzy"`)
35
35
  — each of those needs a `_bidx` column migration, which this script does
36
36
  not write.
37
+
38
+ ## object-form.ts
39
+
40
+ Rewrites the positional registrar calls `r.writeHandler(name, schema, handler, options)`,
41
+ `r.queryHandler(…)`, `r.entity(name, definition, { table })`, `r.requires("a", "b")` and
42
+ `r.hook(type, target, fn, options?)` to their object form, and
43
+ `registerEntityCrud(r, name, entity, options?)` to `r.crud(name, entity, options?)`
44
+ (the now-unused import is removed). Idempotent — object-form calls are left alone.
45
+
46
+ A call is only touched when its receiver is provably a feature registrar: a parameter of
47
+ the `defineFeature(name, (r) => …)` callback, or a binding typed `FeatureRegistrar`.
48
+ Anything else (`sessions.requires("x")`, `foo.entity(…)`) stays untouched. A call on an
49
+ untyped `r`/`registrar` it cannot resolve, a spread argument, too few arguments or
50
+ `entity` options other than `{ table }` are reported as `manual` instead of guessed at.
51
+
52
+ ```bash
53
+ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/object-form.ts <dir> [--check | --dry-run | --write] [--no-format]
54
+ ```
55
+
56
+ Runs automatically via `bun kumiko-upgrade --apply`. Without a mode flag it writes, then
57
+ formats the changed files with the Biome binary found in `node_modules/.bin` (`--no-format`
58
+ skips that). `--dry-run` only reports; `--check` reports and exits 1 while any rewritable
59
+ or manual site is left.