@cosmicdrift/kumiko-framework 0.350.0 → 0.352.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/dist/bun-db/query.js +3 -3
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +4 -3
- package/dist/db/located-timestamp.js +1 -1
- package/dist/db/queries/seed-context.d.ts +17 -0
- package/dist/db/queries/seed-context.js +26 -0
- package/dist/db/table-builder.d.ts +5 -5
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/query-output-columns.js +3 -3
- package/dist/engine/boot-validator/query-refs.js +11 -7
- package/dist/engine/boot-validator/screens.js +89 -9
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/steps/_duration-utils.js +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/es-ops/context.js +15 -1
- package/dist/es-ops/index.d.ts +1 -1
- package/dist/es-ops/types.d.ts +20 -0
- package/dist/event-store/event-store.d.ts +1 -0
- package/dist/event-store/event-store.js +4 -17
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/tenant-db-append.d.ts +8 -0
- package/dist/event-store/tenant-db-append.js +16 -0
- package/dist/i18n/required-surface-keys.js +7 -1
- package/dist/pipeline/dispatch-batch.js +5 -1
- package/dist/pipeline/dispatch-write.js +36 -82
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -0
- package/dist/pipeline/event-dispatcher-delivery.js +3 -10
- package/dist/pipeline/pre-handler-gates.d.ts +9 -0
- package/dist/pipeline/pre-handler-gates.js +104 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +12 -3
- package/dist/pipeline/pre-transaction-reservations.js +34 -21
- package/dist/rate-limit/resolver.js +3 -6
- package/dist/time/legacy-date.d.ts +1 -1
- package/dist/time/legacy-date.js +1 -1
- package/dist/time/polyfill.d.ts +1 -0
- package/dist/time/polyfill.js +14 -33
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +8 -11
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/upgrade-cli.js +39 -35
- package/package.json +4 -4
- package/src/changes.json +27 -0
|
@@ -18,12 +18,16 @@ function rewrapHooksWithOrigin(afterCommitHooks, fromIndex, origin) {
|
|
|
18
18
|
afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
function toReservedScope(reservations, index, cmd) {
|
|
22
|
+
const reservation = reservations.reservedCommands.get(index);
|
|
23
|
+
return reservation && { type: cmd.type, reservation };
|
|
24
|
+
}
|
|
21
25
|
async function runReservedCommand(scope, cmd, index, tx) {
|
|
22
26
|
const { ctx, user, inheritedOrigin, reservations, origins, afterCommitHooks } = scope;
|
|
23
27
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, cmd.type, user), inheritedOrigin);
|
|
24
28
|
origins.push(origin);
|
|
25
29
|
const hookStart = afterCommitHooks.length;
|
|
26
|
-
const res = await runAsCommand(reservations
|
|
30
|
+
const res = await runAsCommand(toReservedScope(reservations, index, cmd), () => runWithWriteOrigin(origin, () => executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks)));
|
|
27
31
|
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
28
32
|
return res;
|
|
29
33
|
}
|
|
@@ -3,18 +3,16 @@ import { asEntityTableMeta, selectMany } from "../db/query.js";
|
|
|
3
3
|
import { buildEntityTable, toSnakeCase } from "../db/table-builder.js";
|
|
4
4
|
import { createTenantDb, hasTenantColumn } from "../db/tenant-db.js";
|
|
5
5
|
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
6
|
-
import { hasAccess } from "../engine/access.js";
|
|
7
6
|
import { ConfigScopes } from "../engine/constants.js";
|
|
8
|
-
import { checkWriteFieldOwnership,
|
|
7
|
+
import { checkWriteFieldOwnership, maskWriteOnlyFields } from "../engine/field-access.js";
|
|
9
8
|
import { defineTransitions, guardTransition } from "../engine/state-machine.js";
|
|
10
9
|
import { HookPhases } from "../engine/types/index.js";
|
|
11
|
-
import {
|
|
12
|
-
import { AccessDeniedError, FrameworkReasons, InternalError, isKumikoError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
10
|
+
import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, NotFoundError, ValidationError, writeFailure, } from "../errors/index.js";
|
|
13
11
|
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
14
12
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
15
|
-
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE,
|
|
13
|
+
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
16
14
|
import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
17
|
-
import {
|
|
15
|
+
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
18
16
|
import { consumeReservation } from "./pre-transaction-reservations.js";
|
|
19
17
|
import { runProjections } from "./projections-runner.js";
|
|
20
18
|
function getTable(ctx, entityName) {
|
|
@@ -306,6 +304,22 @@ export async function executeNestedWrite(ctx, type, payload, user, origin, tx, a
|
|
|
306
304
|
}
|
|
307
305
|
return parentResult;
|
|
308
306
|
}
|
|
307
|
+
async function confirmReservation(reservation, tx, user) {
|
|
308
|
+
if (!reservation.confirmInTransaction)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (!tx) {
|
|
311
|
+
return writeFailure(new InternalError({
|
|
312
|
+
message: "a reservation that settles in the transaction needs a transaction",
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
await reservation.confirmInTransaction({ tx, tenantId: user.tenantId });
|
|
317
|
+
}
|
|
318
|
+
catch (e) {
|
|
319
|
+
return writeFailure(wrapToKumiko(e));
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
309
323
|
async function executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks) {
|
|
310
324
|
const { registry, jobRunner } = ctx;
|
|
311
325
|
const handler = registry.getWriteHandler(type);
|
|
@@ -316,87 +330,22 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
316
330
|
if (isMemberResolutionPrincipal(user)) {
|
|
317
331
|
return writeFailure(memberResolutionReadOnlyDenied());
|
|
318
332
|
}
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// WriteFailure via toWriteErrorInfo. Apps that don't use L3 pay zero cost
|
|
327
|
-
// for non-systemScope handlers with no rateLimit declared; systemScope
|
|
328
|
-
// handlers pay one isHandlerSystemScoped lookup to check whether the
|
|
329
|
-
// default per-tenant limit applies.
|
|
330
|
-
try {
|
|
331
|
-
await enforceRateLimit(ctx, handler.rateLimit, type, user, registry.isHandlerSystemScoped(type));
|
|
332
|
-
}
|
|
333
|
-
catch (e) {
|
|
334
|
-
if (isKumikoError(e))
|
|
335
|
-
return writeFailure(e);
|
|
336
|
-
throw e;
|
|
337
|
-
}
|
|
338
|
-
// Default-deny: missing access rule is treated as "no one has access".
|
|
339
|
-
// The registry boot-validator refuses to register handlers without one,
|
|
340
|
-
// so in normal boots this branch shouldn't fire — the guard is belt-and-
|
|
341
|
-
// suspenders in case a handler sneaks through (e.g. runtime injection).
|
|
342
|
-
if (!hasAccess(user, handler.access)) {
|
|
343
|
-
return writeFailure(handlerAccessError(user, type));
|
|
344
|
-
}
|
|
345
|
-
const parsed = handler.schema.safeParse(payload);
|
|
346
|
-
if (!parsed.success) {
|
|
347
|
-
return writeFailure(validationErrorFromZod(parsed.error));
|
|
348
|
-
}
|
|
333
|
+
// A command the dispatcher reserved for was already billed against the rate limits; the
|
|
334
|
+
// gates only run again against the in-transaction state.
|
|
335
|
+
const reservation = handler.reserveBeforeTransaction ? consumeReservation(type) : undefined;
|
|
336
|
+
const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
|
|
337
|
+
if (!gates.isSuccess)
|
|
338
|
+
return gates;
|
|
339
|
+
const parsedPayload = gates.payload;
|
|
349
340
|
// A handler that reserves before the transaction cannot do so from inside an open one; running
|
|
350
341
|
// it unreserved would skip the check entirely, so a nested ctx.write of a different capped
|
|
351
342
|
// handler is rejected instead.
|
|
352
|
-
if (handler.reserveBeforeTransaction && !
|
|
343
|
+
if (handler.reserveBeforeTransaction && !reservation) {
|
|
353
344
|
return writeFailure(new InternalError({
|
|
354
345
|
message: `handler "${type}" reserves before the transaction and cannot run as a nested write unless the top-level command reserved it`,
|
|
355
346
|
}));
|
|
356
347
|
}
|
|
357
|
-
try {
|
|
358
|
-
await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, parsed.data);
|
|
359
|
-
}
|
|
360
|
-
catch (e) {
|
|
361
|
-
if (isKumikoError(e))
|
|
362
|
-
return writeFailure(e);
|
|
363
|
-
throw e;
|
|
364
|
-
}
|
|
365
|
-
const hookErrors = runValidation(registry, type, parsed.data); // @cast-boundary engine-payload
|
|
366
|
-
if (hookErrors) {
|
|
367
|
-
return writeFailure(new ValidationError({
|
|
368
|
-
fields: hookErrors.map((e) => ({
|
|
369
|
-
path: e.field,
|
|
370
|
-
code: e.error,
|
|
371
|
-
i18nKey: `errors.validation.${e.error}`,
|
|
372
|
-
})),
|
|
373
|
-
}));
|
|
374
|
-
}
|
|
375
|
-
// Field-level write access check
|
|
376
348
|
const entityName = registry.getHandlerEntity(type);
|
|
377
|
-
if (entityName) {
|
|
378
|
-
const entity = registry.getEntity(entityName);
|
|
379
|
-
if (entity) {
|
|
380
|
-
const fieldsToCheck = parsed.data["changes"]; // @cast-boundary engine-payload
|
|
381
|
-
const writePayload = fieldsToCheck ?? parsed.data; // @cast-boundary engine-payload
|
|
382
|
-
// Pre-handler check: role-only gate. Ownership-level row-match runs
|
|
383
|
-
// later in the executor where oldRow is loaded — that split lets
|
|
384
|
-
// updates with partial changes still pass the pre-handler check and
|
|
385
|
-
// get their full evaluation at save time.
|
|
386
|
-
const deniedField = checkWriteFieldRoles(entity, writePayload, user);
|
|
387
|
-
if (deniedField) {
|
|
388
|
-
return writeFailure(new AccessDeniedError({
|
|
389
|
-
message: `field access denied: ${deniedField}`,
|
|
390
|
-
i18nKey: "errors.access.fieldDenied",
|
|
391
|
-
details: {
|
|
392
|
-
reason: FrameworkReasons.fieldAccessDenied,
|
|
393
|
-
field: deniedField,
|
|
394
|
-
handler: type,
|
|
395
|
-
},
|
|
396
|
-
}));
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
349
|
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks);
|
|
401
350
|
// Auto transition guard: if entity has transitions and handler doesn't skip it.
|
|
402
351
|
// Reads via the guard's own db handle — for r.systemScope() handlers
|
|
@@ -409,7 +358,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
409
358
|
if (entityName && !handler.unsafeSkipTransitionGuard) {
|
|
410
359
|
const entity = registry.getEntity(entityName);
|
|
411
360
|
if (entity?.transitions && transitionGuardDb) {
|
|
412
|
-
const parsedData =
|
|
361
|
+
const parsedData = parsedPayload; // @cast-boundary engine-payload
|
|
413
362
|
const changes = parsedData["changes"] ?? parsedData; // @cast-boundary engine-payload
|
|
414
363
|
const id = parsedData["id"] ?? undefined; // @cast-boundary engine-payload
|
|
415
364
|
for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
|
|
@@ -454,7 +403,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
454
403
|
// writeFailure, not via a rethrow) so batches roll back naturally.
|
|
455
404
|
let result;
|
|
456
405
|
try {
|
|
457
|
-
result = await handler.handler({ type, payload:
|
|
406
|
+
result = await handler.handler({ type, payload: parsedPayload, user }, handlerContext);
|
|
458
407
|
}
|
|
459
408
|
catch (e) {
|
|
460
409
|
return writeFailure(wrapToKumiko(e));
|
|
@@ -472,6 +421,11 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
472
421
|
`Use defineWriteHandler() or wrap the return as { isSuccess: true as const, data: ... }.`,
|
|
473
422
|
}));
|
|
474
423
|
}
|
|
424
|
+
if (result.isSuccess && reservation) {
|
|
425
|
+
const confirmFailure = await confirmReservation(reservation, tx, user);
|
|
426
|
+
if (confirmFailure)
|
|
427
|
+
return confirmFailure;
|
|
428
|
+
}
|
|
475
429
|
if (result.isSuccess) {
|
|
476
430
|
try {
|
|
477
431
|
const runner = resolveDbSource(ctx, tx);
|
|
@@ -483,7 +437,7 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
483
437
|
// jobRunner has external side-effects (BullMQ enqueue) — must NOT
|
|
484
438
|
// fire for rolled-back writes. Defer to afterCommit.
|
|
485
439
|
if (jobRunner) {
|
|
486
|
-
const eventData = (
|
|
440
|
+
const eventData = (parsedPayload ?? {}); // @cast-boundary engine-payload
|
|
487
441
|
afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
|
|
488
442
|
}
|
|
489
443
|
invalidateTenantTimezoneCache(ctx, type, user, result);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection.js";
|
|
3
4
|
import { type PendingIdRange } from "../db/queries/event-store.js";
|
|
4
5
|
import type { AppContext } from "../engine/types/index.js";
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
-
|
|
3
|
-
// `Temporal` TYPE that ConsumerStateRow.updatedAt/StoredEventRow.createdAt
|
|
4
|
-
// resolve against (same #1438 dual-package-hazard pattern as event-store.ts).
|
|
5
|
-
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
2
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
3
|
import { requestContext } from "../api/request-context.js";
|
|
7
4
|
import { insertConsumerIfAbsent, markConsumerProcessing, rearmDeadConsumer, selectConsumerForUpdate, selectConsumerForUpdateSkipLocked, selectProvablyIdleConsumerPairs, updateConsumerDeliveryOutcome, } from "../db/queries/event-consumer.js";
|
|
8
5
|
import { selectEventsHeadId, selectPendingAndNewEventRows, } from "../db/queries/event-store.js";
|
|
@@ -54,14 +51,10 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
|
|
|
54
51
|
// a prior re-arm) gates the retry; maxRearmCount stops a poison event
|
|
55
52
|
// from looping forever (re-arm → same event fails → dead → re-arm →
|
|
56
53
|
// ...) — after the cap it stays dead until a human intervenes.
|
|
57
|
-
const cooldownDeadline =
|
|
54
|
+
const cooldownDeadline = Temporal.Now.instant().subtract({
|
|
58
55
|
milliseconds: rearmCooldownMs,
|
|
59
56
|
});
|
|
60
|
-
|
|
61
|
-
// at runtime — state.updatedAt is DB-row-typed against the ambient
|
|
62
|
-
// global, two distinct nominal types across the two .d.ts sources (see
|
|
63
|
-
// event-store.ts).
|
|
64
|
-
const cooldownElapsed = TemporalPolyfill.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
57
|
+
const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
65
58
|
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
66
59
|
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
67
60
|
const rearmedState = rearmed &&
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SessionUser, WriteHandlerDef } from "../engine/types/index.js";
|
|
2
|
+
import { type WriteFailure } from "../errors/index.js";
|
|
3
|
+
import type { DispatchContext } from "./dispatch-shared.js";
|
|
4
|
+
export type PreHandlerGateMode = "charge" | "recheck";
|
|
5
|
+
export type PreHandlerGateResult = {
|
|
6
|
+
readonly isSuccess: true;
|
|
7
|
+
readonly payload: unknown;
|
|
8
|
+
} | WriteFailure;
|
|
9
|
+
export declare function runPreHandlerGates(ctx: DispatchContext, handler: WriteHandlerDef, type: string, rawPayload: unknown, user: SessionUser, mode: PreHandlerGateMode): Promise<PreHandlerGateResult>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { checkWriteFieldRoles } from "../engine/field-access.js";
|
|
3
|
+
import { runValidation } from "../engine/validation.js";
|
|
4
|
+
import { AccessDeniedError, FrameworkReasons, isKumikoError, ValidationError, validationErrorFromZod, writeFailure, } from "../errors/index.js";
|
|
5
|
+
import { checkFeatureEnabled, enforcePayloadRateLimits, enforceRateLimit, } from "./dispatch-shared.js";
|
|
6
|
+
import { handlerAccessError } from "./handler-access-error.js";
|
|
7
|
+
async function rejectDisabledFeature(ctx, type, user) {
|
|
8
|
+
const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
|
|
9
|
+
return disabledErr ? writeFailure(disabledErr) : undefined;
|
|
10
|
+
}
|
|
11
|
+
async function rejectRateLimited(ctx, handler, type, user) {
|
|
12
|
+
try {
|
|
13
|
+
await enforceRateLimit(ctx, handler.rateLimit, type, user, ctx.registry.isHandlerSystemScoped(type));
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
if (isKumikoError(e))
|
|
17
|
+
return writeFailure(e);
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
async function rejectPayloadRateLimited(ctx, handler, type, user, payload) {
|
|
23
|
+
try {
|
|
24
|
+
await enforcePayloadRateLimits(ctx, handler.additionalRateLimits, type, user, payload);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (isKumikoError(e))
|
|
28
|
+
return writeFailure(e);
|
|
29
|
+
throw e;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
function rejectInvalidByHooks(ctx, type, payload) {
|
|
34
|
+
const hookErrors = runValidation(ctx.registry, type, payload); // @cast-boundary engine-payload
|
|
35
|
+
if (!hookErrors)
|
|
36
|
+
return undefined;
|
|
37
|
+
return writeFailure(new ValidationError({
|
|
38
|
+
fields: hookErrors.map((e) => ({
|
|
39
|
+
path: e.field,
|
|
40
|
+
code: e.error,
|
|
41
|
+
i18nKey: `errors.validation.${e.error}`,
|
|
42
|
+
})),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
// Role-only gate; ownership-level row matching runs later in the executor where oldRow is loaded,
|
|
46
|
+
// so updates with partial changes still pass here and get their full evaluation at save time.
|
|
47
|
+
function rejectDeniedFieldRoles(ctx, type, payload, user) {
|
|
48
|
+
const { registry } = ctx;
|
|
49
|
+
const entityName = registry.getHandlerEntity(type);
|
|
50
|
+
const entity = entityName ? registry.getEntity(entityName) : undefined;
|
|
51
|
+
if (!entity)
|
|
52
|
+
return undefined;
|
|
53
|
+
const fieldsToCheck = payload["changes"]; // @cast-boundary engine-payload
|
|
54
|
+
const writePayload = fieldsToCheck ?? payload; // @cast-boundary engine-payload
|
|
55
|
+
const deniedField = checkWriteFieldRoles(entity, writePayload, user);
|
|
56
|
+
if (!deniedField)
|
|
57
|
+
return undefined;
|
|
58
|
+
return writeFailure(new AccessDeniedError({
|
|
59
|
+
message: `field access denied: ${deniedField}`,
|
|
60
|
+
i18nKey: "errors.access.fieldDenied",
|
|
61
|
+
details: {
|
|
62
|
+
reason: FrameworkReasons.fieldAccessDenied,
|
|
63
|
+
field: deniedField,
|
|
64
|
+
handler: type,
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
async function rejectBeforeParse(ctx, handler, type, user, mode) {
|
|
69
|
+
const disabled = await rejectDisabledFeature(ctx, type, user);
|
|
70
|
+
if (disabled)
|
|
71
|
+
return disabled;
|
|
72
|
+
if (mode === "charge") {
|
|
73
|
+
const rateLimited = await rejectRateLimited(ctx, handler, type, user);
|
|
74
|
+
if (rateLimited)
|
|
75
|
+
return rateLimited;
|
|
76
|
+
}
|
|
77
|
+
// Default-deny: boot validation refuses handlers without an access rule, so this only guards
|
|
78
|
+
// against a handler injected at runtime.
|
|
79
|
+
if (!hasAccess(user, handler.access))
|
|
80
|
+
return writeFailure(handlerAccessError(user, type));
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
async function rejectAfterParse(ctx, handler, type, user, payload, mode) {
|
|
84
|
+
if (mode === "charge") {
|
|
85
|
+
const rateLimited = await rejectPayloadRateLimited(ctx, handler, type, user, payload);
|
|
86
|
+
if (rateLimited)
|
|
87
|
+
return rateLimited;
|
|
88
|
+
}
|
|
89
|
+
return (rejectInvalidByHooks(ctx, type, payload) ?? rejectDeniedFieldRoles(ctx, type, payload, user));
|
|
90
|
+
}
|
|
91
|
+
// Every check that runs before a write handler may do work, in dispatch order. A handler that
|
|
92
|
+
// reserves capacity must only reserve for callers who passed all of them.
|
|
93
|
+
export async function runPreHandlerGates(ctx, handler, type, rawPayload, user, mode) {
|
|
94
|
+
const beforeParse = await rejectBeforeParse(ctx, handler, type, user, mode);
|
|
95
|
+
if (beforeParse)
|
|
96
|
+
return beforeParse;
|
|
97
|
+
const parsed = handler.schema.safeParse(rawPayload);
|
|
98
|
+
if (!parsed.success)
|
|
99
|
+
return writeFailure(validationErrorFromZod(parsed.error));
|
|
100
|
+
const afterParse = await rejectAfterParse(ctx, handler, type, user, parsed.data, mode);
|
|
101
|
+
if (afterParse)
|
|
102
|
+
return afterParse;
|
|
103
|
+
return { isSuccess: true, payload: parsed.data };
|
|
104
|
+
}
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import type { SessionUser } from "../engine/types/index.js";
|
|
2
3
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
3
4
|
import type { BatchCommand, DispatchContext } from "./dispatch-shared.js";
|
|
4
5
|
import type { WriteOrigin } from "./write-origin.js";
|
|
5
|
-
|
|
6
|
-
export
|
|
6
|
+
type ReservationConfirm = ReservationHandle["confirmInTransaction"];
|
|
7
|
+
export type ConsumedReservation = {
|
|
8
|
+
readonly confirmInTransaction: ReservationConfirm | undefined;
|
|
9
|
+
};
|
|
10
|
+
export declare function runAsCommand<T>(reserved: {
|
|
11
|
+
readonly type: string;
|
|
12
|
+
readonly reservation: ConsumedReservation;
|
|
13
|
+
} | undefined, fn: () => Promise<T>): Promise<T>;
|
|
14
|
+
export declare function consumeReservation(type: string): ConsumedReservation | undefined;
|
|
7
15
|
export type PreTransactionReservations = {
|
|
8
16
|
readonly isSuccess: true;
|
|
9
|
-
readonly
|
|
17
|
+
readonly reservedCommands: ReadonlyMap<number, ConsumedReservation>;
|
|
10
18
|
releaseAll(): Promise<void>;
|
|
11
19
|
} | {
|
|
12
20
|
readonly isSuccess: false;
|
|
@@ -14,3 +22,4 @@ export type PreTransactionReservations = {
|
|
|
14
22
|
readonly failedIndex: number;
|
|
15
23
|
};
|
|
16
24
|
export declare function reserveBeforeTransaction(ctx: DispatchContext, commands: readonly BatchCommand[], user: SessionUser, inheritedOrigin: WriteOrigin | undefined): Promise<PreTransactionReservations>;
|
|
25
|
+
export {};
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { hasAccess } from "../engine/access.js";
|
|
3
2
|
import { toWriteErrorInfo } from "../errors/index.js";
|
|
4
3
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
5
|
-
import { buildHandlerContext,
|
|
4
|
+
import { buildHandlerContext, isMemberResolutionPrincipal } from "./dispatch-shared.js";
|
|
6
5
|
import { wrapToKumiko } from "./dispatcher-utils.js";
|
|
6
|
+
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
7
7
|
import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
|
|
8
8
|
// One-shot permission per top-level command: the handler that reserved before the transaction
|
|
9
9
|
// consumes it on entry. A capped handler reached any other way (nested ctx.write, writeAs, a
|
|
10
10
|
// second execution of the same type) finds it spent and is rejected instead of running unreserved.
|
|
11
11
|
const reservedForCommand = new AsyncLocalStorage();
|
|
12
|
-
export function runAsCommand(
|
|
13
|
-
|
|
12
|
+
export function runAsCommand(reserved, fn) {
|
|
13
|
+
const store = new Map();
|
|
14
|
+
if (reserved)
|
|
15
|
+
store.set(reserved.type, reserved.reservation);
|
|
16
|
+
return reservedForCommand.run(store, fn);
|
|
14
17
|
}
|
|
15
18
|
export function consumeReservation(type) {
|
|
16
|
-
|
|
19
|
+
const store = reservedForCommand.getStore();
|
|
20
|
+
const reservation = store?.get(type);
|
|
21
|
+
store?.delete(type);
|
|
22
|
+
return reservation;
|
|
23
|
+
}
|
|
24
|
+
function toReservationHandle(taken) {
|
|
25
|
+
if (taken === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof taken === "function") {
|
|
28
|
+
return { release: taken, confirmInTransaction: async () => { } };
|
|
29
|
+
}
|
|
30
|
+
return taken;
|
|
17
31
|
}
|
|
18
32
|
async function releaseAll(ctx, releases) {
|
|
19
33
|
const log = createFallbackLogger("dispatcher", ctx.appContext.log);
|
|
@@ -26,36 +40,35 @@ async function releaseAll(ctx, releases) {
|
|
|
26
40
|
}
|
|
27
41
|
}
|
|
28
42
|
}
|
|
29
|
-
// Same
|
|
30
|
-
// or sends an invalid payload never reserves anything
|
|
43
|
+
// Same gates as the in-transaction run, but before any transaction: a caller who is rate limited,
|
|
44
|
+
// denied or sends an invalid payload never reserves anything, so a rejected request cannot consume
|
|
45
|
+
// capacity or trigger the reservation's side effects.
|
|
31
46
|
export async function reserveBeforeTransaction(ctx, commands, user, inheritedOrigin) {
|
|
32
47
|
const releases = [];
|
|
33
|
-
const
|
|
48
|
+
const reservedCommands = new Map();
|
|
34
49
|
if (isMemberResolutionPrincipal(user))
|
|
35
|
-
return { isSuccess: true,
|
|
50
|
+
return { isSuccess: true, reservedCommands, releaseAll: async () => { } };
|
|
36
51
|
for (const [index, command] of commands.entries()) {
|
|
37
52
|
try {
|
|
38
53
|
const handler = ctx.registry.getWriteHandler(command.type);
|
|
39
54
|
if (!handler?.reserveBeforeTransaction)
|
|
40
55
|
continue;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!parsed.success)
|
|
47
|
-
continue;
|
|
56
|
+
const gates = await runPreHandlerGates(ctx, handler, command.type, command.payload, user, "charge");
|
|
57
|
+
if (!gates.isSuccess) {
|
|
58
|
+
await releaseAll(ctx, releases);
|
|
59
|
+
return { isSuccess: false, error: gates.error, failedIndex: index };
|
|
60
|
+
}
|
|
48
61
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
|
|
49
62
|
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
releases.push(release);
|
|
53
|
-
|
|
63
|
+
const handle = toReservationHandle(await handler.reserveBeforeTransaction({ type: command.type, payload: gates.payload, user }, handlerContext));
|
|
64
|
+
if (handle)
|
|
65
|
+
releases.push(handle.release);
|
|
66
|
+
reservedCommands.set(index, { confirmInTransaction: handle?.confirmInTransaction });
|
|
54
67
|
}
|
|
55
68
|
catch (error) {
|
|
56
69
|
await releaseAll(ctx, releases);
|
|
57
70
|
return { isSuccess: false, error: toWriteErrorInfo(wrapToKumiko(error)), failedIndex: index };
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
|
-
return { isSuccess: true,
|
|
73
|
+
return { isSuccess: true, reservedCommands, releaseAll: () => releaseAll(ctx, releases) };
|
|
61
74
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// `Temporal` TYPE that RateLimitDecision.resetAt resolves against (see
|
|
3
|
-
// event-store.ts for the same #1438 dual-package-hazard pattern).
|
|
4
|
-
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
5
2
|
import { RateLimitError } from "../errors/index.js";
|
|
6
3
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
7
4
|
import { toPublicBucketName } from "./bucket.js";
|
|
@@ -134,7 +131,7 @@ export function createRateLimitResolver(opts) {
|
|
|
134
131
|
const nowMs = now();
|
|
135
132
|
const [allowedFlag, remaining, retryAfterMs] = await client.kumikoRateLimit(`${prefix}${bucket}`, String(config.limit), String(refillRatePerMs), String(cost), String(nowMs), String(ttlSeconds));
|
|
136
133
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
137
|
-
const resetAt =
|
|
134
|
+
const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
138
135
|
return {
|
|
139
136
|
allowed: allowedFlag === 1,
|
|
140
137
|
limit: config.limit,
|
|
@@ -162,7 +159,7 @@ export function createRateLimitResolver(opts) {
|
|
|
162
159
|
const nowMs = now();
|
|
163
160
|
const [remaining, retryAfterMs] = await client.kumikoRateLimitPeek(`${prefix}${bucket}`, String(config.limit), String(refillRatePerMs), String(nowMs));
|
|
164
161
|
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
165
|
-
const resetAt =
|
|
162
|
+
const resetAt = Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs);
|
|
166
163
|
return {
|
|
167
164
|
// peek doesn't deduct, so a "would-be" allowed flag is meaningful:
|
|
168
165
|
// true iff at least one token is available right now.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Temporal } from "temporal
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
2
|
export declare function instantToLegacyDate(instant: Temporal.Instant): Date;
|
|
3
3
|
/** Gegenrichtung: Date aus einer Fremd-Lib (imapflow INTERNALDATE,
|
|
4
4
|
* mailparser Date-Header) → Temporal.Instant. */
|
package/dist/time/legacy-date.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Temporal } from "temporal
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
2
|
// Temporal→Date-Bridge für Lib-APIs die JS-Date verlangen (imapflow
|
|
3
3
|
// search({since}), nodemailer-Header, ...). Gegenstück zur
|
|
4
4
|
// dateToInstant-Richtung der DB-/Event-Store-Boundaries: Feature-Code
|
package/dist/time/polyfill.d.ts
CHANGED
package/dist/time/polyfill.js
CHANGED
|
@@ -1,45 +1,26 @@
|
|
|
1
1
|
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
2
|
-
// Temporal
|
|
2
|
+
// Temporal bootstrap.
|
|
3
3
|
//
|
|
4
|
-
// Temporal is native in
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Temporal is native in Bun >= 1.4 and current Chromium/Firefox, but missing in
|
|
5
|
+
// Safari, iOS and Hermes. `@cosmicdrift/kumiko-types/temporal` resolves one
|
|
6
|
+
// implementation (native first, polyfill otherwise) so framework, consumer and
|
|
7
|
+
// ambient-global code all see the same classes.
|
|
7
8
|
//
|
|
8
9
|
// Idempotent: if `globalThis.Temporal` already exists the call is a no-op.
|
|
9
|
-
// The
|
|
10
|
-
//
|
|
11
|
-
// Re-install
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
10
|
+
// The check is the live global — not a sticky module flag — so tests that
|
|
11
|
+
// delete the ambient global (fw#1550) still get it back on the next call.
|
|
12
|
+
// Re-install assigns the already-resolved module value: re-importing
|
|
13
|
+
// `temporal-polyfill` here would put a polyfill instance on the global while
|
|
14
|
+
// the rest of the code uses the native classes (instanceof mismatch).
|
|
15
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
15
16
|
/**
|
|
16
17
|
* Ensure `globalThis.Temporal` is available. Idempotent.
|
|
17
18
|
*/
|
|
18
19
|
export async function ensureTemporalPolyfill() {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
// skip: Temporal global already present (native or installed by an earlier call)
|
|
21
|
+
if ("Temporal" in globalThis)
|
|
21
22
|
return;
|
|
22
|
-
}
|
|
23
|
-
if (polyfillPromise) {
|
|
24
|
-
await polyfillPromise;
|
|
25
|
-
if ("Temporal" in globalThis) {
|
|
26
|
-
// skip: Concurrent boot — peer call finished install
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
// Peer resolved but global still missing (torn down mid-flight).
|
|
30
|
-
polyfillPromise = null;
|
|
31
|
-
}
|
|
32
|
-
polyfillPromise = (async () => {
|
|
33
|
-
if ("Temporal" in globalThis) {
|
|
34
|
-
// skip: raced native/peer install
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
// Value export — assign ourselves so teardown + re-call still works
|
|
38
|
-
// (side-effect `temporal-polyfill/global` is ESM-cached and silent on re-import).
|
|
39
|
-
const { Temporal } = await import("temporal-polyfill");
|
|
40
|
-
Object.assign(globalThis, { Temporal });
|
|
41
|
-
})();
|
|
42
|
-
await polyfillPromise;
|
|
23
|
+
Object.assign(globalThis, { Temporal });
|
|
43
24
|
}
|
|
44
25
|
/**
|
|
45
26
|
* Type-safe access to globalThis.Temporal. Throws if the polyfill has not
|
|
@@ -2,7 +2,7 @@ import type { TzContext, TzContextOptions } from "@cosmicdrift/kumiko-types/tz-c
|
|
|
2
2
|
export type { LocatedTimestampJson, TzContext, TzContextOptions, } from "@cosmicdrift/kumiko-types/tz-context";
|
|
3
3
|
/**
|
|
4
4
|
* Factory: creates a TzContext for the current request.
|
|
5
|
-
* Uses
|
|
5
|
+
* Uses the kumiko-types Temporal module export (not globalThis.Temporal) so
|
|
6
6
|
* buildHandlerContext stays ambient-free (fw#1525/#1550).
|
|
7
7
|
*/
|
|
8
8
|
export declare function createTzContext(options?: TzContextOptions): TzContext;
|
package/dist/time/tz-context.js
CHANGED
|
@@ -17,33 +17,30 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The pure type contracts (TzContext, TzContextOptions, LocatedTimestampJson)
|
|
19
19
|
// live in @cosmicdrift/kumiko-types/tz-context — only the factories are here.
|
|
20
|
-
import { Temporal
|
|
20
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
21
21
|
import { ensureTemporalPolyfill } from "./polyfill.js";
|
|
22
22
|
/**
|
|
23
23
|
* Factory: creates a TzContext for the current request.
|
|
24
|
-
* Uses
|
|
24
|
+
* Uses the kumiko-types Temporal module export (not globalThis.Temporal) so
|
|
25
25
|
* buildHandlerContext stays ambient-free (fw#1525/#1550).
|
|
26
26
|
*/
|
|
27
27
|
export function createTzContext(options = {}) {
|
|
28
28
|
const tenant = options.tenant ?? "UTC";
|
|
29
29
|
const user = options.user ?? tenant;
|
|
30
30
|
const geoTz = options.geoTz;
|
|
31
|
-
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal values at
|
|
32
|
-
// runtime; TzContext is typed against ambient Temporal from temporal-spec.
|
|
33
|
-
const T = TemporalPolyfill;
|
|
34
31
|
return {
|
|
35
32
|
tenant,
|
|
36
33
|
user,
|
|
37
|
-
now: () =>
|
|
38
|
-
nowIn: (tz) =>
|
|
39
|
-
today: (tz) =>
|
|
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
|
|
40
37
|
todayRange: (tz) => {
|
|
41
|
-
const today =
|
|
38
|
+
const today = Temporal.Now.plainDateISO(tz);
|
|
42
39
|
const startZdt = today.toZonedDateTime({ timeZone: tz });
|
|
43
40
|
const endZdt = today.add({ days: 1 }).toZonedDateTime({ timeZone: tz });
|
|
44
41
|
return { start: startZdt.toInstant(), end: endZdt.toInstant() };
|
|
45
42
|
},
|
|
46
|
-
parse: (wallClock, tz) =>
|
|
43
|
+
parse: (wallClock, tz) => Temporal.PlainDateTime.from(wallClock).toZonedDateTime(tz),
|
|
47
44
|
toInstant: (zdt) => zdt.toInstant(),
|
|
48
45
|
toLocatedJson: (zdt) => ({
|
|
49
46
|
// Wall-clock WITHOUT offset (no "Z", no "+01:00") plus the IANA name.
|
|
@@ -52,7 +49,7 @@ export function createTzContext(options = {}) {
|
|
|
52
49
|
at: zdt.toPlainDateTime().toString(),
|
|
53
50
|
tz: zdt.timeZoneId,
|
|
54
51
|
}),
|
|
55
|
-
fromLocatedJson: (obj) =>
|
|
52
|
+
fromLocatedJson: (obj) => Temporal.PlainDateTime.from(obj.at).toZonedDateTime(obj.tz),
|
|
56
53
|
fromCoordinates: async (coords) => {
|
|
57
54
|
if (geoTz === undefined) {
|
|
58
55
|
throw new Error("ctx.tz.fromCoordinates requires a GeoTzProvider — inject one via the app context (e.g. buildServer({ context: { geoTzProvider } }) or runProdApp({ extraContext: { geoTzProvider } })) or install a provider package.");
|