@cosmicdrift/kumiko-framework 0.365.0 → 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 (114) 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 +12 -2
  49. package/agents/examples/writeform-section.md +21 -15
  50. package/agents/features.md +1 -1
  51. package/agents/widgets.md +5 -0
  52. package/dist/api/server.js +20 -4
  53. package/dist/bun-db/query.js +16 -1
  54. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  55. package/dist/crypto/pg-kms-adapter.js +10 -6
  56. package/dist/db/entity-table-meta.js +4 -4
  57. package/dist/db/event-store-executor-write.js +16 -12
  58. package/dist/engine/boot-validator/api-ext.js +2 -2
  59. package/dist/engine/boot-validator/config-deps.js +1 -1
  60. package/dist/engine/boot-validator/handler-field-references.js +3 -10
  61. package/dist/engine/boot-validator/screens.js +1 -1
  62. package/dist/engine/build-app-schema.js +3 -6
  63. package/dist/engine/define-feature.js +5 -5
  64. package/dist/engine/entity-handlers.d.ts +2 -2
  65. package/dist/engine/entity-handlers.js +5 -5
  66. package/dist/engine/factories.d.ts +11 -11
  67. package/dist/engine/factories.js +11 -11
  68. package/dist/engine/feature-ast/extractors/round2.js +16 -2
  69. package/dist/engine/feature-ast/render.js +4 -7
  70. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  71. package/dist/engine/feature-config-events-jobs.js +3 -0
  72. package/dist/engine/feature-entity-handlers.d.ts +4 -23
  73. package/dist/engine/feature-entity-handlers.js +77 -100
  74. package/dist/engine/feature-ui-extensions.d.ts +9 -14
  75. package/dist/engine/feature-ui-extensions.js +22 -14
  76. package/dist/engine/handler-field-references.d.ts +25 -0
  77. package/dist/engine/handler-field-references.js +80 -6
  78. package/dist/engine/index.d.ts +3 -3
  79. package/dist/engine/index.js +2 -2
  80. package/dist/engine/object-form.d.ts +2 -0
  81. package/dist/engine/object-form.js +13 -10
  82. package/dist/engine/registry-state.js +2 -2
  83. package/dist/engine/registry-validate.d.ts +2 -0
  84. package/dist/engine/registry-validate.js +42 -2
  85. package/dist/engine/registry.js +3 -1
  86. package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
  87. package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
  88. package/dist/engine/types/index.d.ts +2 -2
  89. package/dist/files/feature.js +1 -1
  90. package/dist/jobs/job-runner.d.ts +1 -0
  91. package/dist/jobs/job-runner.js +83 -17
  92. package/dist/jobs/max-per-tenant.d.ts +8 -0
  93. package/dist/jobs/max-per-tenant.js +14 -0
  94. package/dist/pipeline/dispatch-query.js +1 -1
  95. package/dist/pipeline/dispatch-shared.d.ts +2 -1
  96. package/dist/pipeline/dispatch-shared.js +3 -6
  97. package/dist/pipeline/index.d.ts +2 -0
  98. package/dist/pipeline/index.js +1 -0
  99. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  100. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  101. package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
  102. package/dist/pipeline/tenant-timezone-config-key.js +6 -0
  103. package/dist/testing/index.d.ts +1 -0
  104. package/dist/testing/index.js +1 -0
  105. package/dist/testing/manual-clock.d.ts +14 -0
  106. package/dist/testing/manual-clock.js +12 -0
  107. package/dist/time/index.d.ts +1 -1
  108. package/dist/time/tz-context.d.ts +1 -1
  109. package/dist/time/tz-context.js +9 -4
  110. package/dist/upgrade-cli.js +13 -2
  111. package/package.json +4 -4
  112. package/src/changes.json +58 -0
  113. package/src/scripts/codemod/README.md +23 -0
  114. package/src/scripts/codemod/object-form.ts +709 -0
@@ -1,6 +1,7 @@
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
- import { Queue, UnrecoverableError, Worker } from "bullmq";
4
+ import { DelayedError, Queue, UnrecoverableError, Worker } from "bullmq";
4
5
  import { Redis } from "ioredis";
5
6
  import { requestContext } from "../api/request-context.js";
6
7
  import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db.js";
@@ -16,9 +17,12 @@ 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";
25
+ import { resolveMaxPerTenant } from "./max-per-tenant.js";
22
26
  // Shape-checked after the BullMQ/Redis JSON round-trip. Unlike `_writeOrigin`
23
27
  // (a security-relevant escape hatch that must fail the run closed), a
24
28
  // malformed `_triggerEvent` only costs an idempotency-key convenience — the
@@ -200,6 +204,14 @@ function jobSubjectKey(jobName, payload, fields, declaration) {
200
204
  }
201
205
  return serializeJobSubject(subject);
202
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
+ }
203
215
  // Jobs that never touch ctx.db run fine on a runner built without one; a job
204
216
  // that does gets a named error instead of a bare TypeError on undefined.
205
217
  function missingDbProxy(jobName) {
@@ -248,6 +260,9 @@ function tenantFailureMessageKey(err, declaration) {
248
260
  // Leading underscore matches the existing internal-meta convention
249
261
  // (_triggeredById, _tenantId, _payload).
250
262
  const TRACE_CONTEXT_KEY = "_traceContext";
263
+ const DEFER_COUNT_KEY = "_deferCount";
264
+ const DEFER_DELAY_CAP_MS = 30_000;
265
+ const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
251
266
  function readTraceContext(data) {
252
267
  const raw = data[TRACE_CONTEXT_KEY];
253
268
  if (!raw || typeof raw !== "object")
@@ -555,18 +570,23 @@ export function createJobRunner(options) {
555
570
  // lane (jobDef.runIn is static), but walking both is cheap and avoids
556
571
  // a subtle bug if someone ever reassigns a job to a different lane
557
572
  // between deploys while old queue contents are still draining.
558
- async function isOverPerTenantLimit(jobName, tenantId, max) {
573
+ // `activeOnly` + `self` serve the defer check, which runs inside the job being
574
+ // counted: it must not see waiting jobs or itself, and only counts jobs that
575
+ // became active before it. Without that order, workers that fetch a burst of
576
+ // jobs together all see each other as active and all defer, again and again.
577
+ async function isOverPerTenantLimit(jobName, tenantId, max, scope = {}) {
559
578
  const results = await Promise.all([
560
579
  queues.api.getActive(),
561
- queues.api.getWaiting(),
562
580
  queues.worker.getActive(),
563
- queues.worker.getWaiting(),
581
+ ...(scope.activeOnly ? [] : [queues.api.getWaiting(), queues.worker.getWaiting()]),
564
582
  ]);
565
583
  let count = 0;
566
584
  for (const list of results) {
567
585
  for (const j of list) {
568
586
  if (j.name !== jobName)
569
587
  continue;
588
+ if (scope.self && !isActiveBefore(j, scope.self))
589
+ continue;
570
590
  const t = j.data?._tenantId; // @cast-boundary dynamic-key
571
591
  if (t === tenantId) {
572
592
  count += 1;
@@ -577,6 +597,22 @@ export function createJobRunner(options) {
577
597
  }
578
598
  return false;
579
599
  }
600
+ function isActiveBefore(other, self) {
601
+ if (other.id === self.id)
602
+ return false;
603
+ if (self.processedOn === undefined)
604
+ return true;
605
+ const otherProcessedOn = other.processedOn ?? 0;
606
+ if (otherProcessedOn !== self.processedOn)
607
+ return otherProcessedOn < self.processedOn;
608
+ return (other.id ?? "") < (self.id ?? "");
609
+ }
610
+ // Growing delay bounds Redis and worker load while one tenant has a large
611
+ // backlog: every deferred job would otherwise be re-checked every deferMs.
612
+ function deferDelayMs(deferMs, deferCount) {
613
+ const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
614
+ return grown + Math.floor(Math.random() * deferMs);
615
+ }
580
616
  // Only err.message, never payload or the logs array: job payloads can carry PII.
581
617
  function logFailedAttempt(entry) {
582
618
  errorLogger.error("job failed", {
@@ -647,7 +683,7 @@ export function createJobRunner(options) {
647
683
  });
648
684
  }
649
685
  }
650
- async function handleJob(bullJob) {
686
+ async function handleJob(bullJob, token) {
651
687
  const rawName = bullJob.name;
652
688
  // Handle perTenant dispatch jobs — fan out to one job per tenant. The
653
689
  // fan-out re-enqueues into the lane the actual job is assigned to;
@@ -729,6 +765,25 @@ export function createJobRunner(options) {
729
765
  await reportPreRunFailure(jobName, bullJob, err);
730
766
  throw err;
731
767
  }
768
+ // maxPerTenant defer: checked before anything is recorded for this run, so a
769
+ // deferred pickup leaves no span, run-log entry or failure trace and does not
770
+ // consume an attempt (moveToDelayed + DelayedError, not a retry).
771
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
772
+ const deferTenantId = bullJob.data["_tenantId"];
773
+ if (maxPerTenant?.onLimit === "defer" &&
774
+ typeof deferTenantId === "string" &&
775
+ token !== undefined &&
776
+ bullJob.moveToDelayed &&
777
+ (await isOverPerTenantLimit(jobName, deferTenantId, maxPerTenant.limit, {
778
+ activeOnly: true,
779
+ self: { id: bullJob.id, processedOn: bullJob.processedOn },
780
+ }))) {
781
+ const priorDeferCount = bullJob.data[DEFER_COUNT_KEY];
782
+ const deferCount = typeof priorDeferCount === "number" ? priorDeferCount : 0;
783
+ await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
784
+ await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
785
+ throw new DelayedError();
786
+ }
732
787
  // Sequential gate: try to claim the per-name lock. If another worker
733
788
  // (or this worker on a different bullJob) holds it, re-enqueue with a
734
789
  // small delay and exit *successfully* — using throw would burn the
@@ -940,7 +995,7 @@ export function createJobRunner(options) {
940
995
  return dispatchWriteRef.write(user, qn, payload, jobOrigin);
941
996
  },
942
997
  }, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
943
- const jobContext = {
998
+ const jobContextWithoutTz = {
944
999
  ...context,
945
1000
  db: jobDb ?? missingDbProxy(jobName),
946
1001
  files,
@@ -1016,7 +1071,10 @@ export function createJobRunner(options) {
1016
1071
  handler: jobName,
1017
1072
  feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
1018
1073
  writeOrigin: jobOrigin,
1019
- }, () => jobDef.handler(payload, jobContext));
1074
+ }, async () => jobDef.handler(payload, {
1075
+ ...jobContextWithoutTz,
1076
+ tz: await resolveJobTz(config, context),
1077
+ }));
1020
1078
  // Stamped before the observer hook: a throwing onJobComplete must not
1021
1079
  // make a run that actually succeeded look dead to the liveness alert.
1022
1080
  if (context.meter) {
@@ -1202,7 +1260,7 @@ export function createJobRunner(options) {
1202
1260
  return;
1203
1261
  }
1204
1262
  const consumerQueue = queues[consumerLane];
1205
- worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), handleJob, {
1263
+ worker = new Worker(queueNameFor(queueNamePrefix, consumerLane), (job, token) => handleJob(job, token), {
1206
1264
  connection: redisOpts,
1207
1265
  concurrency: 5,
1208
1266
  // Not consuming until the boot gates passed: a job already queued from a
@@ -1361,10 +1419,11 @@ export function createJobRunner(options) {
1361
1419
  // against; without it the guard is inactive (system jobs, ambient
1362
1420
  // dispatch). Fan-out children of perTenant jobs land here on their
1363
1421
  // recursive queue.add and DO carry _tenantId.
1364
- if (jobDef.maxPerTenant !== undefined) {
1422
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
1423
+ if (maxPerTenant?.onLimit === "skip") {
1365
1424
  const tenantId = payload?._tenantId; // @cast-boundary dynamic-key
1366
1425
  if (tenantId !== undefined &&
1367
- (await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))) {
1426
+ (await isOverPerTenantLimit(jobName, tenantId, maxPerTenant.limit))) {
1368
1427
  return "skipped:max-per-tenant";
1369
1428
  }
1370
1429
  }
@@ -1406,6 +1465,8 @@ export function createJobRunner(options) {
1406
1465
  bullOpts["timeout"] = jobDef.timeout;
1407
1466
  if (meta?.priority !== undefined)
1408
1467
  bullOpts["priority"] = meta.priority;
1468
+ if (meta?.jobId !== undefined)
1469
+ bullOpts["jobId"] = meta.jobId;
1409
1470
  // Pack meta into job data with _ prefix
1410
1471
  const data = { ...payload };
1411
1472
  if (meta?.triggeredById !== undefined)
@@ -1467,8 +1528,9 @@ export function createJobRunner(options) {
1467
1528
  data["_correlationId"] = reqCtx.correlationId;
1468
1529
  // Same maxPerTenant guard as dispatch — events that fan into many
1469
1530
  // jobs must respect the per-tenant cap or the limit is one-sided.
1470
- if (jobDef.maxPerTenant !== undefined && user?.tenantId !== undefined) {
1471
- if (await isOverPerTenantLimit(name, String(user.tenantId), jobDef.maxPerTenant)) {
1531
+ const maxPerTenant = resolveMaxPerTenant(jobDef);
1532
+ if (maxPerTenant?.onLimit === "skip" && user?.tenantId !== undefined) {
1533
+ if (await isOverPerTenantLimit(name, String(user.tenantId), maxPerTenant.limit)) {
1472
1534
  continue;
1473
1535
  }
1474
1536
  }
@@ -1485,17 +1547,21 @@ export function createJobRunner(options) {
1485
1547
  async countPendingJobs() {
1486
1548
  let total = 0;
1487
1549
  for (const queue of Object.values(queues)) {
1488
- const counts = await queue.getJobCounts("waiting", "active", "prioritized", "waiting-children");
1550
+ // One getJobCounts call is a single atomic snapshot. Reading delayed in a
1551
+ // second call misses a deferred job that cycles delayed -> active between
1552
+ // the reads, so delayed is counted here and only repeat schedulers (which
1553
+ // stay delayed between cron fires) are subtracted afterwards.
1554
+ const counts = await queue.getJobCounts("waiting", "active", "prioritized", "waiting-children", "delayed");
1489
1555
  total +=
1490
1556
  (counts["waiting"] ?? 0) +
1491
1557
  (counts["active"] ?? 0) +
1492
1558
  (counts["prioritized"] ?? 0) +
1493
- (counts["waiting-children"] ?? 0);
1494
- // getJobCounts has no id-prefix filter for "delayed" — getJobs(["delayed"])
1495
- // loads the full Job objects (payload included) just to read .id; fine for
1559
+ (counts["waiting-children"] ?? 0) +
1560
+ (counts["delayed"] ?? 0);
1561
+ // getJobs(["delayed"]) loads full Job objects just to read .id; fine for
1496
1562
  // test-draining's low volume, not a pattern for a prod-path hot loop.
1497
1563
  const delayed = await queue.getJobs(["delayed"]);
1498
- total += delayed.filter((job) => !(job.id ?? "").startsWith("repeat:")).length;
1564
+ total -= delayed.filter((job) => (job.id ?? "").startsWith("repeat:")).length;
1499
1565
  }
1500
1566
  return total;
1501
1567
  },
@@ -0,0 +1,8 @@
1
+ import type { JobDefinition } from "../engine/types/index.js";
2
+ export declare const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1000;
3
+ export type ResolvedMaxPerTenant = {
4
+ readonly limit: number;
5
+ readonly onLimit: "skip" | "defer";
6
+ readonly deferMs: number;
7
+ };
8
+ export declare function resolveMaxPerTenant(jobDef: Pick<JobDefinition, "maxPerTenant">): ResolvedMaxPerTenant | undefined;
@@ -0,0 +1,14 @@
1
+ export const DEFAULT_MAX_PER_TENANT_DEFER_MS = 1_000;
2
+ export function resolveMaxPerTenant(jobDef) {
3
+ const raw = jobDef.maxPerTenant;
4
+ if (raw === undefined)
5
+ return undefined;
6
+ if (typeof raw === "number") {
7
+ return { limit: raw, onLimit: "skip", deferMs: DEFAULT_MAX_PER_TENANT_DEFER_MS };
8
+ }
9
+ return {
10
+ limit: raw.limit,
11
+ onLimit: raw.onLimit,
12
+ deferMs: raw.deferMs ?? DEFAULT_MAX_PER_TENANT_DEFER_MS,
13
+ };
14
+ }
@@ -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
  });
@@ -31,6 +31,8 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
31
31
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
32
32
  export type { MspRebuildDeps } from "./msp-rebuild.js";
33
33
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
34
+ export type { MultiStreamApplyContext, MultiStreamApplyContextDeps, } from "./multi-stream-apply-context.js";
35
+ export { createMultiStreamApplyContext } from "./multi-stream-apply-context.js";
34
36
  export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
35
37
  export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
36
38
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
@@ -16,6 +16,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
16
16
  export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
17
17
  export { createLifecycleHooks } from "./lifecycle-pipeline.js";
18
18
  export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
19
+ export { createMultiStreamApplyContext } from "./multi-stream-apply-context.js";
19
20
  export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
20
21
  export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
21
22
  export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
@@ -14,5 +14,6 @@ export type MultiStreamApplyContextDeps = {
14
14
  readonly files?: FileContext;
15
15
  readonly derivatives?: DerivativesContext;
16
16
  readonly secrets?: SecretsContext;
17
+ readonly jobs?: MultiStreamApplyContext["jobs"];
17
18
  };
18
19
  export declare function createMultiStreamApplyContext(deps: MultiStreamApplyContextDeps): MultiStreamApplyContext;
@@ -7,6 +7,7 @@ export function createMultiStreamApplyContext(deps) {
7
7
  ...(deps.files ? { files: deps.files } : {}),
8
8
  ...(deps.derivatives ? { derivatives: deps.derivatives } : {}),
9
9
  ...(deps.secrets ? { secrets: deps.secrets } : {}),
10
+ ...(deps.jobs ? { jobs: deps.jobs } : {}),
10
11
  appendEvent: (async (args) => {
11
12
  await appendDomainEventCore({
12
13
  registry: deps.registry,
@@ -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.0",
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.0",
214
- "@cosmicdrift/kumiko-types": "0.365.0",
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.0",
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,62 @@
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
+ },
42
+ {
43
+ "version": "0.365.1",
44
+ "type": "fix",
45
+ "title": "App and worker boot wait for Postgres instead of crashing",
46
+ "detail": "runProdApp and worker processes now probe the database before the schema-drift check and retry with exponential backoff (1s to 15s, up to 90s) on transient connection errors such as too_many_connections or ECONNREFUSED. The KMS health check at boot retries the same way. Non-transient errors like auth failures still abort immediately."
47
+ },
48
+ {
49
+ "version": "0.365.1",
50
+ "type": "fix",
51
+ "title": "Restoring a soft-deleted row with a money field no longer fails with a 500",
52
+ "detail": "restore() wrote the raw stored row (money as bigint minor units) into payload.previous, and the jsonb bind threw. It now builds previous in the same domain form as delete(), so the restored event carries { amount, currency, ... } for money fields."
53
+ },
54
+ {
55
+ "version": "0.365.1",
56
+ "type": "fix",
57
+ "title": "version_conflict from delete, restore, forget and update reports the actual stream version",
58
+ "detail": "After a lost append race, delete, restore and forget returned currentVersion -1 and update returned the version read before the race, so expectedVersion and currentVersion looked equal. They now look up the stream version after the conflict; -1 remains only when that lookup fails."
59
+ },
2
60
  {
3
61
  "version": "0.365.0",
4
62
  "type": "improvement",
@@ -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.