@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.
- package/AGENTS.md +2 -2
- package/agents/examples/access-control.md +32 -30
- package/agents/examples/anonymous-access-multitenant.md +14 -16
- package/agents/examples/anonymous-access.md +36 -43
- package/agents/examples/auth-foundation-providers.md +1 -1
- package/agents/examples/basic-entity.md +1 -1
- package/agents/examples/boot-check.md +3 -3
- package/agents/examples/chat-channels.md +1 -1
- package/agents/examples/cross-feature-events.md +7 -7
- package/agents/examples/currencies-global.md +3 -3
- package/agents/examples/currencies-per-tenant.md +2 -2
- package/agents/examples/custom-fields-basic.md +4 -3
- package/agents/examples/custom-handlers.md +40 -44
- package/agents/examples/dashboard-stats.md +6 -6
- package/agents/examples/delivery-notifications.md +9 -9
- package/agents/examples/designer-demo.md +2 -1
- package/agents/examples/embedded-entity-form.md +13 -13
- package/agents/examples/embedded.md +1 -1
- package/agents/examples/encrypted-tenant-config.md +1 -1
- package/agents/examples/error-contract.md +25 -25
- package/agents/examples/event-sourcing.md +96 -76
- package/agents/examples/feature-toggles.md +29 -21
- package/agents/examples/files-post-processing.md +22 -26
- package/agents/examples/idempotency.md +17 -19
- package/agents/examples/inbound-mail-triage.md +1 -1
- package/agents/examples/lane-routing.md +10 -12
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +59 -39
- package/agents/examples/managed-config.md +9 -9
- package/agents/examples/managed-pages.md +1 -1
- package/agents/examples/notes-history-basic.md +3 -3
- package/agents/examples/pdf-audit-export.md +7 -7
- package/agents/examples/pipeline-basics.md +8 -8
- package/agents/examples/public-share-token.md +1 -1
- package/agents/examples/rate-limiting.md +7 -9
- package/agents/examples/record-detail-layout.md +26 -20
- package/agents/examples/reference-data.md +1 -1
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +2 -2
- package/agents/examples/search.md +17 -19
- package/agents/examples/security-baseline.md +1 -1
- package/agents/examples/state-machine.md +1 -1
- package/agents/examples/tags-basic.md +3 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/timezones.md +12 -12
- package/agents/examples/user-data-rights.md +3 -3
- package/agents/examples/user-profile.md +1 -1
- package/agents/examples/webhook-step.md +12 -2
- package/agents/examples/writeform-section.md +21 -15
- package/agents/features.md +1 -1
- package/agents/widgets.md +5 -0
- package/dist/api/server.js +20 -4
- package/dist/bun-db/query.js +16 -1
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/pg-kms-adapter.js +10 -6
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-write.js +16 -12
- package/dist/engine/boot-validator/api-ext.js +2 -2
- package/dist/engine/boot-validator/config-deps.js +1 -1
- package/dist/engine/boot-validator/handler-field-references.js +3 -10
- package/dist/engine/boot-validator/screens.js +1 -1
- package/dist/engine/build-app-schema.js +3 -6
- package/dist/engine/define-feature.js +5 -5
- package/dist/engine/entity-handlers.d.ts +2 -2
- package/dist/engine/entity-handlers.js +5 -5
- package/dist/engine/factories.d.ts +11 -11
- package/dist/engine/factories.js +11 -11
- package/dist/engine/feature-ast/extractors/round2.js +16 -2
- package/dist/engine/feature-ast/render.js +4 -7
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -0
- package/dist/engine/feature-entity-handlers.d.ts +4 -23
- package/dist/engine/feature-entity-handlers.js +77 -100
- package/dist/engine/feature-ui-extensions.d.ts +9 -14
- package/dist/engine/feature-ui-extensions.js +22 -14
- package/dist/engine/handler-field-references.d.ts +25 -0
- package/dist/engine/handler-field-references.js +80 -6
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +2 -2
- package/dist/engine/object-form.d.ts +2 -0
- package/dist/engine/object-form.js +13 -10
- package/dist/engine/registry-state.js +2 -2
- package/dist/engine/registry-validate.d.ts +2 -0
- package/dist/engine/registry-validate.js +42 -2
- package/dist/engine/registry.js +3 -1
- package/dist/engine/strip-disabled-features-from-app-schema.d.ts +6 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +89 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/files/feature.js +1 -1
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +83 -17
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/pipeline/dispatch-query.js +1 -1
- package/dist/pipeline/dispatch-shared.d.ts +2 -1
- package/dist/pipeline/dispatch-shared.js +3 -6
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- package/dist/pipeline/tenant-timezone-config-key.d.ts +1 -0
- package/dist/pipeline/tenant-timezone-config-key.js +6 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/manual-clock.d.ts +14 -0
- package/dist/testing/manual-clock.js +12 -0
- package/dist/time/index.d.ts +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +9 -4
- package/dist/upgrade-cli.js +13 -2
- package/package.json +4 -4
- package/src/changes.json +58 -0
- package/src/scripts/codemod/README.md +23 -0
- package/src/scripts/codemod/object-form.ts +709 -0
package/dist/jobs/job-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1495
|
-
// loads
|
|
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
|
|
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",
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -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";
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/testing/index.js
CHANGED
|
@@ -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 {};
|
package/dist/time/index.d.ts
CHANGED
|
@@ -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
|
package/dist/time/tz-context.js
CHANGED
|
@@ -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: () =>
|
|
35
|
-
nowIn
|
|
36
|
-
today: (tz) =>
|
|
39
|
+
now: () => clock.now(),
|
|
40
|
+
nowIn,
|
|
41
|
+
today: (tz) => nowIn(tz).toPlainDate(),
|
|
37
42
|
todayRange: (tz) => {
|
|
38
|
-
const today =
|
|
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() };
|
package/dist/upgrade-cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
214
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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.
|