@cosmicdrift/kumiko-framework 0.363.0 → 0.364.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 +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +1 -1
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
|
@@ -11,12 +11,13 @@ import { AccessDeniedError, FrameworkReasons, InternalError, memberResolutionRea
|
|
|
11
11
|
import { assertFileRefsBelongToTenant } from "../files/assert-file-refs-belong-to-tenant.js";
|
|
12
12
|
import { bindPendingFileRefs } from "../files/bind-pending-file-refs.js";
|
|
13
13
|
import { disposeRemovedFileRefs } from "../files/dispose-removed-file-refs.js";
|
|
14
|
+
import { loadSaveFileRefs } from "../files/load-save-file-refs.js";
|
|
14
15
|
import { CACHE_SYNC_TOPICS } from "../redis/cache-sync-topics.js";
|
|
15
16
|
import { assertNoSecretLeak } from "../secrets/index.js";
|
|
16
|
-
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
17
|
+
import { buildHandlerContext, CONFIG_WRITE_RESET_TYPE, CONFIG_WRITE_SET_TYPE, isMemberResolutionPrincipal, perCommandNestedDenied, resolveDbSource, runHandlerInstrumented, TENANT_TIMEZONE_CONFIG_KEY, } from "./dispatch-shared.js";
|
|
17
18
|
import { describeShape, extractNestedSpecs, isLifecycleResult, isWriteResultShape, prefixValidationPath, wrapToKumiko, } from "./dispatcher-utils.js";
|
|
18
19
|
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
19
|
-
import { consumeReservation } from "./pre-transaction-reservations.js";
|
|
20
|
+
import { consumeReservation, hasPreTransactionPhase, } from "./pre-transaction-reservations.js";
|
|
20
21
|
import { runProjections } from "./projections-runner.js";
|
|
21
22
|
function getTable(ctx, entityName) {
|
|
22
23
|
const { registry, tableCache } = ctx;
|
|
@@ -115,12 +116,13 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
115
116
|
await runProjections(result, handlerContext.registry, runner);
|
|
116
117
|
if (result.kind === "save") {
|
|
117
118
|
const fileRefArgs = { registry: handlerContext.registry, result, user, db: handlerContext.db };
|
|
118
|
-
await
|
|
119
|
-
|
|
119
|
+
const fileRefRows = await loadSaveFileRefs(fileRefArgs);
|
|
120
|
+
assertFileRefsBelongToTenant({ ...fileRefArgs, fileRefRows });
|
|
121
|
+
await bindPendingFileRefs({ ...fileRefArgs, fileRefRows });
|
|
120
122
|
await disposeRemovedFileRefs(fileRefArgs);
|
|
121
123
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
122
124
|
afterCommitHooks.push(async () => {
|
|
123
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined,
|
|
125
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
|
|
124
126
|
await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
125
127
|
});
|
|
126
128
|
}
|
|
@@ -128,7 +130,7 @@ async function runLifecycle(ctx, type, data, handlerContext, user, origin, after
|
|
|
128
130
|
await lifecycle.runPreDelete(type, result, handlerContext);
|
|
129
131
|
await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
|
|
130
132
|
afterCommitHooks.push(async () => {
|
|
131
|
-
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined,
|
|
133
|
+
const afterCommitContext = await buildHandlerContext(ctx, type, user, origin, undefined, undefined, undefined, "postCommit");
|
|
132
134
|
await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
133
135
|
});
|
|
134
136
|
}
|
|
@@ -337,23 +339,29 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
337
339
|
if (isMemberResolutionPrincipal(user)) {
|
|
338
340
|
return writeFailure(memberResolutionReadOnlyDenied());
|
|
339
341
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
const isPerCommand = handler.transaction === "perCommand";
|
|
343
|
+
if (isPerCommand && tx !== undefined) {
|
|
344
|
+
return writeFailure(perCommandNestedDenied(type));
|
|
345
|
+
}
|
|
346
|
+
// A command the dispatcher ran pre-transaction phases for was already billed against the rate
|
|
347
|
+
// limits; the gates only run again against the in-transaction state.
|
|
348
|
+
const needsPreTransactionPhase = hasPreTransactionPhase(handler);
|
|
349
|
+
const reservation = needsPreTransactionPhase ? consumeReservation(type) : undefined;
|
|
343
350
|
const gates = await runPreHandlerGates(ctx, handler, type, payload, user, reservation ? "recheck" : "charge");
|
|
344
351
|
if (!gates.isSuccess)
|
|
345
352
|
return gates;
|
|
346
353
|
const parsedPayload = gates.payload;
|
|
347
|
-
// A handler
|
|
348
|
-
// it
|
|
349
|
-
// handler is rejected instead.
|
|
350
|
-
if (
|
|
354
|
+
// A handler with a pre-transaction phase cannot run that phase from inside an open transaction;
|
|
355
|
+
// running it without would skip the reservation or the prepared data, so a nested ctx.write of
|
|
356
|
+
// such a handler is rejected instead.
|
|
357
|
+
if (needsPreTransactionPhase && !reservation) {
|
|
351
358
|
return writeFailure(new InternalError({
|
|
352
|
-
message: `handler "${type}"
|
|
359
|
+
message: `handler "${type}" has a pre-transaction phase (reserveBeforeTransaction/beforeTransaction) and cannot run as a nested write unless the top-level command ran it`,
|
|
353
360
|
}));
|
|
354
361
|
}
|
|
355
362
|
const entityName = registry.getHandlerEntity(type);
|
|
356
|
-
|
|
363
|
+
// Without a database there is no transaction either, but ordinary handlers keep the in-transaction nested-write semantics.
|
|
364
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks, undefined, isPerCommand ? "withoutTransaction" : "inTransaction");
|
|
357
365
|
// Auto transition guard: if entity has transitions and handler doesn't skip it.
|
|
358
366
|
// Reads via the guard's own db handle — for r.systemScope() handlers
|
|
359
367
|
// handlerContext.db is fail-closed, so this reaches for
|
|
@@ -410,7 +418,8 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
410
418
|
// writeFailure, not via a rethrow) so batches roll back naturally.
|
|
411
419
|
let result;
|
|
412
420
|
try {
|
|
413
|
-
|
|
421
|
+
const event = { type, payload: parsedPayload, user, prepared: reservation?.prepared };
|
|
422
|
+
result = await handler.handler(event, handlerContext);
|
|
414
423
|
}
|
|
415
424
|
catch (e) {
|
|
416
425
|
return writeFailure(wrapToKumiko(e));
|
|
@@ -434,12 +443,15 @@ async function executeWriteInner(ctx, type, payload, user, origin, tx, afterComm
|
|
|
434
443
|
return confirmFailure;
|
|
435
444
|
}
|
|
436
445
|
if (result.isSuccess) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
// A perCommand handler's writes ran as their own commands; its result carries no SaveContext of its own to project.
|
|
447
|
+
if (!isPerCommand) {
|
|
448
|
+
try {
|
|
449
|
+
const runner = resolveDbSource(ctx, tx);
|
|
450
|
+
await runLifecycle(ctx, type, result.data, handlerContext, user, origin, afterCommitHooks, runner);
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
return writeFailure(wrapToKumiko(e));
|
|
454
|
+
}
|
|
443
455
|
}
|
|
444
456
|
// jobRunner has external side-effects (BullMQ enqueue) — must NOT
|
|
445
457
|
// fire for rolled-back writes. Defer to afterCommit.
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { EscapeHatchReportWindow } from "../observability/escape-hatch-report.js";
|
|
2
2
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
3
|
+
export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
|
|
3
4
|
export type { AppendDomainEventCoreDeps } from "./append-event-core.js";
|
|
4
5
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
5
6
|
export type { ResolveAuthClaimsArgs } from "./auth-claims-resolver.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, flushEscapeHatchAudits, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, withEscapeHatchAuditScope, } from "../observability/escape-hatch-report.js";
|
|
2
|
+
export { normalizePayloadBucketValue } from "../rate-limit/bucket.js";
|
|
2
3
|
export { appendDomainEventCore } from "./append-event-core.js";
|
|
3
4
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
4
5
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ReservationHandle } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
import type { SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import type { SessionUser, WriteHandlerDef } from "../engine/types/index.js";
|
|
3
3
|
import { type WriteErrorInfo } from "../errors/index.js";
|
|
4
4
|
import type { BatchCommand, DispatchContext } from "./dispatch-shared.js";
|
|
5
5
|
import type { WriteOrigin } from "./write-origin.js";
|
|
6
6
|
type ReservationConfirm = ReservationHandle["confirmInTransaction"];
|
|
7
7
|
export type ConsumedReservation = {
|
|
8
8
|
readonly confirmInTransaction: ReservationConfirm | undefined;
|
|
9
|
+
readonly prepared: unknown;
|
|
9
10
|
};
|
|
11
|
+
export declare function hasPreTransactionPhase(handler: WriteHandlerDef): boolean;
|
|
10
12
|
export declare function runAsCommand<T>(reserved: {
|
|
11
13
|
readonly type: string;
|
|
12
14
|
readonly reservation: ConsumedReservation;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { toWriteErrorInfo } from "../errors/index.js";
|
|
3
3
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
4
|
-
import { buildHandlerContext, isMemberResolutionPrincipal } from "./dispatch-shared.js";
|
|
4
|
+
import { applyBeforeTransactionReadOnly, buildHandlerContext, denyTransactionBoundSurfaces, isMemberResolutionPrincipal, } from "./dispatch-shared.js";
|
|
5
5
|
import { wrapToKumiko } from "./dispatcher-utils.js";
|
|
6
6
|
import { runPreHandlerGates } from "./pre-handler-gates.js";
|
|
7
7
|
import { effectiveWriteOrigin, rootWriteOrigin } from "./write-origin.js";
|
|
8
|
+
export function hasPreTransactionPhase(handler) {
|
|
9
|
+
return handler.reserveBeforeTransaction !== undefined || handler.beforeTransaction !== undefined;
|
|
10
|
+
}
|
|
8
11
|
// One-shot permission per top-level command: the handler that reserved before the transaction
|
|
9
12
|
// consumes it on entry. A capped handler reached any other way (nested ctx.write, writeAs, a
|
|
10
13
|
// second execution of the same type) finds it spent and is rejected instead of running unreserved.
|
|
@@ -36,13 +39,13 @@ async function releaseAll(ctx, releases) {
|
|
|
36
39
|
await release();
|
|
37
40
|
}
|
|
38
41
|
catch (error) {
|
|
39
|
-
log.error("releasing a pre-transaction
|
|
42
|
+
log.error("releasing a pre-transaction phase failed", { error });
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
// 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
|
|
45
|
-
// capacity or trigger
|
|
47
|
+
// denied or sends an invalid payload never reserves or prepares anything, so a rejected request
|
|
48
|
+
// cannot consume capacity or trigger external calls.
|
|
46
49
|
export async function reserveBeforeTransaction(ctx, commands, user, inheritedOrigin) {
|
|
47
50
|
const releases = [];
|
|
48
51
|
const reservedCommands = new Map();
|
|
@@ -51,7 +54,7 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
51
54
|
for (const [index, command] of commands.entries()) {
|
|
52
55
|
try {
|
|
53
56
|
const handler = ctx.registry.getWriteHandler(command.type);
|
|
54
|
-
if (!handler
|
|
57
|
+
if (!handler || !hasPreTransactionPhase(handler))
|
|
55
58
|
continue;
|
|
56
59
|
const gates = await runPreHandlerGates(ctx, handler, command.type, command.payload, user, "charge");
|
|
57
60
|
if (!gates.isSuccess) {
|
|
@@ -60,10 +63,26 @@ export async function reserveBeforeTransaction(ctx, commands, user, inheritedOri
|
|
|
60
63
|
}
|
|
61
64
|
const origin = effectiveWriteOrigin(rootWriteOrigin(ctx.registry, command.type, user), inheritedOrigin);
|
|
62
65
|
const handlerContext = await buildHandlerContext(ctx, command.type, user, origin);
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const event = { type: command.type, payload: gates.payload, user };
|
|
67
|
+
let confirmInTransaction;
|
|
68
|
+
if (handler.reserveBeforeTransaction) {
|
|
69
|
+
const handle = toReservationHandle(await handler.reserveBeforeTransaction(event, denyTransactionBoundSurfaces(handlerContext)));
|
|
70
|
+
if (handle)
|
|
71
|
+
releases.push(handle.release);
|
|
72
|
+
confirmInTransaction = handle?.confirmInTransaction;
|
|
73
|
+
}
|
|
74
|
+
let prepared;
|
|
75
|
+
if (handler.beforeTransaction) {
|
|
76
|
+
const phase = await handler.beforeTransaction(event, applyBeforeTransactionReadOnly(handlerContext));
|
|
77
|
+
if (!phase.isSuccess) {
|
|
78
|
+
await releaseAll(ctx, releases);
|
|
79
|
+
return { isSuccess: false, error: phase.error, failedIndex: index };
|
|
80
|
+
}
|
|
81
|
+
if (phase.release)
|
|
82
|
+
releases.push(phase.release);
|
|
83
|
+
prepared = phase.data;
|
|
84
|
+
}
|
|
85
|
+
reservedCommands.set(index, { confirmInTransaction, prepared });
|
|
67
86
|
}
|
|
68
87
|
catch (error) {
|
|
69
88
|
await releaseAll(ctx, releases);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RateLimitOption, SessionUser } from "../engine/types/index.js";
|
|
1
|
+
import type { PayloadBucketNormalization, RateLimitOption, SessionUser } from "../engine/types/index.js";
|
|
2
2
|
export type BucketContext = {
|
|
3
3
|
readonly handlerName: string;
|
|
4
4
|
readonly user: SessionUser;
|
|
@@ -12,7 +12,7 @@ export type BucketResult = {
|
|
|
12
12
|
readonly reason: string;
|
|
13
13
|
};
|
|
14
14
|
export declare function buildBucketKey(option: RateLimitOption, ctx: BucketContext): BucketResult;
|
|
15
|
-
export declare function normalizePayloadBucketValue(value: string): string;
|
|
16
|
-
export declare function buildPayloadBucketKey(handlerName: string, field: string, digest: string): string;
|
|
15
|
+
export declare function normalizePayloadBucketValue(value: string, normalize?: PayloadBucketNormalization): string;
|
|
16
|
+
export declare function buildPayloadBucketKey(tenantId: string, handlerName: string, field: string, digest: string): string;
|
|
17
17
|
export declare function toPublicBucketName(bucket: string): string;
|
|
18
18
|
export declare function createPayloadDigest(masterSecret: string): (value: string) => string;
|
|
@@ -23,11 +23,21 @@ export function buildBucketKey(option, ctx) {
|
|
|
23
23
|
// Payload-field buckets: the value (e.g. a recipient address) is normalized
|
|
24
24
|
// and HMAC-hashed so Redis never holds it in plaintext.
|
|
25
25
|
const PAYLOAD_BUCKET_PURPOSE = "kumiko:rate-limit:payload-bucket";
|
|
26
|
-
export function normalizePayloadBucketValue(value) {
|
|
27
|
-
|
|
26
|
+
export function normalizePayloadBucketValue(value, normalize) {
|
|
27
|
+
const base = value.normalize("NFKC").trim().toLowerCase();
|
|
28
|
+
return normalize === "email" ? stripEmailPlusTag(base) : base;
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// Only the `+tag` suffix is dropped; provider-specific dot rules are not applied.
|
|
31
|
+
function stripEmailPlusTag(address) {
|
|
32
|
+
const at = address.lastIndexOf("@");
|
|
33
|
+
if (at === -1)
|
|
34
|
+
return address;
|
|
35
|
+
const local = address.slice(0, at);
|
|
36
|
+
const plus = local.indexOf("+");
|
|
37
|
+
return plus <= 0 ? address : `${local.slice(0, plus)}${address.slice(at)}`;
|
|
38
|
+
}
|
|
39
|
+
export function buildPayloadBucketKey(tenantId, handlerName, field, digest) {
|
|
40
|
+
return `payload+handler:${tenantId}:${handlerName}:${field}:${digest}`;
|
|
31
41
|
}
|
|
32
42
|
// Everything after the scope tag identifies the caller (IP, user/tenant id,
|
|
33
43
|
// auth target, payload digest). IPv6 addresses and handler names contain ":",
|
|
@@ -64,7 +64,12 @@ function createLocalFanout() {
|
|
|
64
64
|
export function createLocalCacheSyncBus() {
|
|
65
65
|
const fanout = createLocalFanout();
|
|
66
66
|
return {
|
|
67
|
-
publish
|
|
67
|
+
publish(topic, message, options) {
|
|
68
|
+
// skip: a single-process bus has no remote subscribers to reach
|
|
69
|
+
if (options?.remoteOnly)
|
|
70
|
+
return;
|
|
71
|
+
fanout.deliver(topic, message);
|
|
72
|
+
},
|
|
68
73
|
subscribe: fanout.subscribe,
|
|
69
74
|
onResync: fanout.onResync,
|
|
70
75
|
async close() { },
|
|
@@ -108,8 +113,9 @@ export function createCacheSyncBusOverSignal(signal, opts) {
|
|
|
108
113
|
}
|
|
109
114
|
});
|
|
110
115
|
return {
|
|
111
|
-
publish(topic, message) {
|
|
112
|
-
|
|
116
|
+
publish(topic, message, options) {
|
|
117
|
+
if (!options?.remoteOnly)
|
|
118
|
+
fanout.deliver(topic, message);
|
|
113
119
|
const envelope = { origin, topic, message };
|
|
114
120
|
signal.publish(opts.channel, envelope);
|
|
115
121
|
},
|
|
@@ -23,6 +23,27 @@ const REMOVE_TOLERATED_ERROR_CODES = [ErrorStatusCode.INDEX_NOT_FOUND];
|
|
|
23
23
|
const INDEX_LIST_PAGE_SIZE = 100;
|
|
24
24
|
// What an unconfigured Meilisearch index reports as searchableAttributes.
|
|
25
25
|
const MEILI_ALL_ATTRIBUTES = "*";
|
|
26
|
+
const DEFAULT_FILTERABLE_ATTRIBUTES = ["_type", "_weight"];
|
|
27
|
+
const DEFAULT_SORTABLE_ATTRIBUTES = ["_weight"];
|
|
28
|
+
function defaultSearchableAttributes(config) {
|
|
29
|
+
return config.rankingFields ?? config.searchableFields;
|
|
30
|
+
}
|
|
31
|
+
// Meilisearch may return granular filterable objects; those are never part of our default.
|
|
32
|
+
function isSubsetOf(stored, wanted) {
|
|
33
|
+
return stored.every((attribute) => typeof attribute === "string" && wanted.includes(attribute));
|
|
34
|
+
}
|
|
35
|
+
// A stored list that lies inside the current default and misses attributes is an
|
|
36
|
+
// older default that gained fields since; anything with a foreign attribute is
|
|
37
|
+
// an explicit configure() and stays untouched.
|
|
38
|
+
function isOutdatedDefault(stored, wanted) {
|
|
39
|
+
const pairs = [
|
|
40
|
+
[stored.searchable, wanted.searchable],
|
|
41
|
+
[stored.filterable, wanted.filterable],
|
|
42
|
+
[stored.sortable, wanted.sortable],
|
|
43
|
+
];
|
|
44
|
+
return (pairs.every(([have, want]) => isSubsetOf(have, want)) &&
|
|
45
|
+
pairs.some(([have, want]) => want.some((attribute) => !have.includes(attribute))));
|
|
46
|
+
}
|
|
26
47
|
function isIndexNotFound(err) {
|
|
27
48
|
const cause = typeof err === "object" && err !== null && "cause" in err ? err.cause : null;
|
|
28
49
|
return errorCodeOf(cause) === ErrorStatusCode.INDEX_NOT_FOUND;
|
|
@@ -54,10 +75,9 @@ export function createMeilisearchAdapter(options) {
|
|
|
54
75
|
}
|
|
55
76
|
async function applyConfig(tenantId, config) {
|
|
56
77
|
const index = client.index(meilisearchTenantIndex(prefix, tenantId));
|
|
57
|
-
|
|
58
|
-
await awaitSucceededTask(index.
|
|
59
|
-
await awaitSucceededTask(index.
|
|
60
|
-
await awaitSucceededTask(index.updateSortableAttributes(["_weight"]));
|
|
78
|
+
await awaitSucceededTask(index.updateSearchableAttributes([...defaultSearchableAttributes(config)]));
|
|
79
|
+
await awaitSucceededTask(index.updateFilterableAttributes([...DEFAULT_FILTERABLE_ATTRIBUTES]));
|
|
80
|
+
await awaitSucceededTask(index.updateSortableAttributes([...DEFAULT_SORTABLE_ATTRIBUTES]));
|
|
61
81
|
}
|
|
62
82
|
// Lazily configures a tenant's index off the default config on its first
|
|
63
83
|
// access, so callers that never call `configure` still get a filterable/
|
|
@@ -67,15 +87,22 @@ export function createMeilisearchAdapter(options) {
|
|
|
67
87
|
// Meili outage doesn't permanently poison the tenant for the process.
|
|
68
88
|
// The configured-tenants map is process memory only: after a restart a stored explicit
|
|
69
89
|
// configure() must not be overwritten by the registry default, so the default is applied only
|
|
70
|
-
// to an index that is missing
|
|
71
|
-
|
|
90
|
+
// to an index that is missing, carries Meilisearch's factory settings, or holds an older
|
|
91
|
+
// default that is a strict subset of the current one (fields were added by a deploy).
|
|
92
|
+
async function indexNeedsDefaultConfig(tenantId, config) {
|
|
72
93
|
try {
|
|
73
94
|
const settings = await client.index(meilisearchTenantIndex(prefix, tenantId)).getSettings();
|
|
74
95
|
const searchable = settings.searchableAttributes ?? [MEILI_ALL_ATTRIBUTES];
|
|
75
96
|
const filterable = settings.filterableAttributes ?? [];
|
|
76
|
-
|
|
97
|
+
const factorySettings = searchable.length === 1 &&
|
|
77
98
|
searchable[0] === MEILI_ALL_ATTRIBUTES &&
|
|
78
|
-
!filterable.includes("_type")
|
|
99
|
+
!filterable.includes("_type");
|
|
100
|
+
return (factorySettings ||
|
|
101
|
+
isOutdatedDefault({ searchable, filterable, sortable: settings.sortableAttributes ?? [] }, {
|
|
102
|
+
searchable: defaultSearchableAttributes(config),
|
|
103
|
+
filterable: DEFAULT_FILTERABLE_ATTRIBUTES,
|
|
104
|
+
sortable: DEFAULT_SORTABLE_ATTRIBUTES,
|
|
105
|
+
}));
|
|
79
106
|
}
|
|
80
107
|
catch (err) {
|
|
81
108
|
if (isIndexNotFound(err))
|
|
@@ -95,7 +122,7 @@ export function createMeilisearchAdapter(options) {
|
|
|
95
122
|
}
|
|
96
123
|
const config = defaultConfig;
|
|
97
124
|
await trackConfigure(tenantId, async () => {
|
|
98
|
-
if (await indexNeedsDefaultConfig(tenantId))
|
|
125
|
+
if (await indexNeedsDefaultConfig(tenantId, config))
|
|
99
126
|
await applyConfig(tenantId, config);
|
|
100
127
|
});
|
|
101
128
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.364.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.364.0",
|
|
214
|
+
"@cosmicdrift/kumiko-types": "0.364.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.364.0",
|
|
231
231
|
"bun-types": "^1.3.13",
|
|
232
232
|
"pino-pretty": "^13.1.3"
|
|
233
233
|
},
|
|
@@ -419,6 +419,8 @@
|
|
|
419
419
|
"src/changes.json",
|
|
420
420
|
"src/scripts/codemod",
|
|
421
421
|
"README.md",
|
|
422
|
-
"LICENSE"
|
|
422
|
+
"LICENSE",
|
|
423
|
+
"AGENTS.md",
|
|
424
|
+
"agents"
|
|
423
425
|
]
|
|
424
426
|
}
|