@cosmicdrift/kumiko-framework 0.351.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/db/queries/seed-context.d.ts +17 -0
- package/dist/db/queries/seed-context.js +26 -0
- 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-refs.js +7 -2
- package/dist/engine/boot-validator/screens.js +15 -7
- package/dist/engine/build-app-schema.js +3 -0
- 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/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/pipeline/dispatch-batch.js +5 -1
- package/dist/pipeline/dispatch-write.js +36 -82
- 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/package.json +4 -4
- package/src/changes.json +27 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DbRunner } from "../connection.js";
|
|
1
2
|
import type { AnyDb } from "../query.js";
|
|
2
3
|
export type SeedUserRow = {
|
|
3
4
|
readonly id: string;
|
|
@@ -18,3 +19,19 @@ export type SeedTenantDbRow = {
|
|
|
18
19
|
export declare function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null>;
|
|
19
20
|
export declare function selectMembershipsOfUser(db: AnyDb, userId: string): Promise<readonly SeedMembershipDbRow[]>;
|
|
20
21
|
export declare function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]>;
|
|
22
|
+
export type SeedTemplateResourceDbRow = {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly tenant_id: string;
|
|
25
|
+
readonly slug: string;
|
|
26
|
+
readonly kind: string;
|
|
27
|
+
readonly locale: string;
|
|
28
|
+
readonly status: string;
|
|
29
|
+
};
|
|
30
|
+
export type SeedTemplateResourceDbFilter = {
|
|
31
|
+
readonly tenantId: string;
|
|
32
|
+
readonly slug?: string;
|
|
33
|
+
readonly kind?: string;
|
|
34
|
+
readonly status?: string;
|
|
35
|
+
readonly locale?: string;
|
|
36
|
+
};
|
|
37
|
+
export declare function selectTemplateResources(db: DbRunner, filter: SeedTemplateResourceDbFilter): Promise<readonly SeedTemplateResourceDbRow[]>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { unsafeReadRetrying } from "../query.js";
|
|
2
|
+
import { tableExists } from "../schema-inspection.js";
|
|
3
|
+
import { SOFT_DELETE_LIVE_ROW_PREDICATE } from "../table-builder.js";
|
|
2
4
|
export async function selectUserByEmail(db, email) {
|
|
3
5
|
const rows = await unsafeReadRetrying(db, `SELECT id::text AS id, email, tenant_id::text AS tenant_id
|
|
4
6
|
FROM read_users
|
|
@@ -23,3 +25,27 @@ export async function selectAllTenants(db) {
|
|
|
23
25
|
FROM read_tenants
|
|
24
26
|
ORDER BY inserted_at`, []);
|
|
25
27
|
}
|
|
28
|
+
// Column names are fixed fragments; every filter value is a bound parameter.
|
|
29
|
+
// template-resolver is an optional feature — without its table the answer is
|
|
30
|
+
// "no templates", not an error.
|
|
31
|
+
export async function selectTemplateResources(db, filter) {
|
|
32
|
+
if (!(await tableExists(db, "read_template_resources")))
|
|
33
|
+
return [];
|
|
34
|
+
const conditions = ["tenant_id = $1", SOFT_DELETE_LIVE_ROW_PREDICATE];
|
|
35
|
+
const params = [filter.tenantId];
|
|
36
|
+
for (const [column, value] of [
|
|
37
|
+
["slug", filter.slug],
|
|
38
|
+
["kind", filter.kind],
|
|
39
|
+
["status", filter.status],
|
|
40
|
+
["locale", filter.locale],
|
|
41
|
+
]) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
params.push(value);
|
|
45
|
+
conditions.push(`${column} = $${params.length}`);
|
|
46
|
+
}
|
|
47
|
+
return unsafeReadRetrying(db, `SELECT id::text AS id, tenant_id::text AS tenant_id, slug, kind, locale, status
|
|
48
|
+
FROM read_template_resources
|
|
49
|
+
WHERE ${conditions.join(" AND ")}
|
|
50
|
+
ORDER BY slug, locale`, params);
|
|
51
|
+
}
|
|
@@ -19,6 +19,7 @@ export declare function validateFileFields(feature: FeatureDefinition): boolean;
|
|
|
19
19
|
export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
20
20
|
export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
21
21
|
export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
|
|
22
|
+
export declare function validateEntitySelectAvailabilityQuery(feature: FeatureDefinition, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
22
23
|
export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
|
|
23
24
|
export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
24
25
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
@@ -692,6 +692,23 @@ function assertNoSelectOptionsQuery(field, path, featureName) {
|
|
|
692
692
|
throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
|
|
693
693
|
}
|
|
694
694
|
}
|
|
695
|
+
// A typo'd availability QN would only surface as a failed query when the form
|
|
696
|
+
// mounts, so it is pinned at boot like the other option queries.
|
|
697
|
+
export function validateEntitySelectAvailabilityQuery(feature, queryHandlers) {
|
|
698
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
699
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
700
|
+
if (field.type !== "select" || field.optionsAvailabilityQuery === undefined)
|
|
701
|
+
continue;
|
|
702
|
+
const query = field.optionsAvailabilityQuery;
|
|
703
|
+
if (query.length === 0 || !queryHandlers.has(query)) {
|
|
704
|
+
throw new Error(`[Feature ${feature.name}] Select field "${fieldName}" on entity "${entityName}" ` +
|
|
705
|
+
`declares optionsAvailabilityQuery "${query}" which is not a registered query-handler. ` +
|
|
706
|
+
`Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
|
|
707
|
+
`is declared via r.queryHandler(...).`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
695
712
|
export function validateMultiSelectFields(feature) {
|
|
696
713
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
697
714
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -9,7 +9,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
|
|
|
9
9
|
import { validateFeatureBootChecks } from "./boot-check.js";
|
|
10
10
|
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
11
11
|
import { collectDetailForScreens } from "./detail-screens.js";
|
|
12
|
-
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
12
|
+
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectAvailabilityQuery, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
13
13
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
14
14
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
15
15
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
@@ -169,6 +169,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
169
169
|
validateRecordOwnedSubjects(feature);
|
|
170
170
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
171
171
|
validateEntitySelectOptionsQuery(feature);
|
|
172
|
+
validateEntitySelectAvailabilityQuery(feature, queryHandlerQns);
|
|
172
173
|
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
173
174
|
validateMultiSelectFields(feature);
|
|
174
175
|
validateImageVariants(feature);
|
|
@@ -45,9 +45,14 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
45
45
|
}
|
|
46
46
|
function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
|
|
47
47
|
for (const [fieldName, field] of Object.entries(fields)) {
|
|
48
|
-
if (field.type !== "select"
|
|
48
|
+
if (field.type !== "select")
|
|
49
49
|
continue;
|
|
50
|
-
|
|
50
|
+
if (field.optionsQuery !== undefined) {
|
|
51
|
+
checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
|
|
52
|
+
}
|
|
53
|
+
if (field.optionsAvailabilityQuery !== undefined) {
|
|
54
|
+
checkQueryRef(queryHandlers, field.optionsAvailabilityQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsAvailabilityQuery`);
|
|
55
|
+
}
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
@@ -493,7 +493,8 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
|
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
// Static options XOR optionsQuery on inline-form select fields, and `{ field }`
|
|
496
|
-
// payload refs must name another field of the same form
|
|
496
|
+
// payload refs must name another field of the same form (the payload also feeds
|
|
497
|
+
// optionsAvailabilityQuery). The QN existence check
|
|
497
498
|
// lives in query-refs.ts with the other query refs.
|
|
498
499
|
function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
|
|
499
500
|
if (fdef.type === "select") {
|
|
@@ -507,6 +508,12 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
|
|
|
507
508
|
}
|
|
508
509
|
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
509
510
|
}
|
|
511
|
+
else if (fdef.optionsAvailabilityQuery !== undefined) {
|
|
512
|
+
if (fdef.optionsAvailabilityQuery.length === 0) {
|
|
513
|
+
throw new Error(`${where} has an empty optionsAvailabilityQuery`);
|
|
514
|
+
}
|
|
515
|
+
validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
|
|
516
|
+
}
|
|
510
517
|
else if (fdef.optionsQueryPayload !== undefined) {
|
|
511
518
|
throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
|
|
512
519
|
}
|
|
@@ -1736,6 +1743,11 @@ function validateDashboardPanelVisibleWhen(context, visibleWhen) {
|
|
|
1736
1743
|
throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
|
|
1737
1744
|
}
|
|
1738
1745
|
}
|
|
1746
|
+
function validateDashboardPanelSpan(featureName, screenId, panel) {
|
|
1747
|
+
if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
|
|
1748
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1739
1751
|
function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
|
|
1740
1752
|
const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
|
|
1741
1753
|
if (panel.stats.length === 0) {
|
|
@@ -1744,6 +1756,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
|
|
|
1744
1756
|
if (panel.subtitle !== undefined && panel.label === undefined) {
|
|
1745
1757
|
throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header.`);
|
|
1746
1758
|
}
|
|
1759
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
1747
1760
|
validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
|
|
1748
1761
|
for (const stat of panel.stats) {
|
|
1749
1762
|
addPanelId(stat.id, "stat-group child");
|
|
@@ -1856,12 +1869,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
|
|
|
1856
1869
|
if (!panel.query || typeof panel.query !== "string") {
|
|
1857
1870
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
|
|
1858
1871
|
}
|
|
1859
|
-
|
|
1860
|
-
panel.span !== undefined &&
|
|
1861
|
-
panel.span !== "half" &&
|
|
1862
|
-
panel.span !== "full") {
|
|
1863
|
-
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
|
|
1864
|
-
}
|
|
1872
|
+
validateDashboardPanelSpan(featureName, screenId, panel);
|
|
1865
1873
|
if (panel.kind === "stat") {
|
|
1866
1874
|
if (panel.valueField.length === 0) {
|
|
1867
1875
|
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
|
|
@@ -428,6 +428,9 @@ function projectField(fieldDef) {
|
|
|
428
428
|
// fw#2780: author-supplied picker source — without it the client falls back to the list handler.
|
|
429
429
|
if (typeof def["optionsQuery"] === "string")
|
|
430
430
|
out["optionsQuery"] = def["optionsQuery"];
|
|
431
|
+
// Select: tier/tenant availability of the static options, loaded by the renderer.
|
|
432
|
+
if (typeof def["optionsAvailabilityQuery"] === "string")
|
|
433
|
+
out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
|
|
431
434
|
if (typeof def["multiple"] === "boolean")
|
|
432
435
|
out["multiple"] = def["multiple"];
|
|
433
436
|
// MultiSelect: display picks checkboxes vs. combobox in the renderer,
|
package/dist/es-ops/context.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
|
|
7
7
|
// config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
|
|
8
8
|
// → audit-fähig.
|
|
9
|
-
import { selectAllTenants, selectMembershipsOfUser, selectUserByEmail, } from "../db/queries/seed-context.js";
|
|
9
|
+
import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
|
|
10
10
|
import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
|
|
11
11
|
import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
|
|
12
12
|
/** Builder: gibt eine factory-function zurück die der Runner pro-Migration
|
|
@@ -65,6 +65,20 @@ export function createSeedMigrationContext(args) {
|
|
|
65
65
|
const rows = await selectAllTenants(args.dbRunner);
|
|
66
66
|
return rows.map((r) => ({ id: r.id, name: r.name, tenantKey: r.tenant_key }));
|
|
67
67
|
},
|
|
68
|
+
findTemplateResources: async (filter) => {
|
|
69
|
+
const rows = await selectTemplateResources(args.dbRunner, {
|
|
70
|
+
...filter,
|
|
71
|
+
tenantId: filter?.tenantId ?? SYSTEM_TENANT_ID,
|
|
72
|
+
});
|
|
73
|
+
return rows.map((r) => ({
|
|
74
|
+
id: r.id,
|
|
75
|
+
tenantId: r.tenant_id,
|
|
76
|
+
slug: r.slug,
|
|
77
|
+
kind: r.kind,
|
|
78
|
+
locale: r.locale,
|
|
79
|
+
status: r.status,
|
|
80
|
+
}));
|
|
81
|
+
},
|
|
68
82
|
db: args.dbRunner,
|
|
69
83
|
};
|
|
70
84
|
}
|
package/dist/es-ops/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { type CreateSeedMigrationContextArgs, createSeedMigrationContext, } from "./context.js";
|
|
2
2
|
export { createEsOperationsTable, type EsOperationAppliedBy, type EsOperationType, esOperationsTable, } from "./operations-schema.js";
|
|
3
3
|
export { type RunPendingSeedMigrationsArgs, type RunPendingSeedMigrationsResult, runPendingSeedMigrations, } from "./runner.js";
|
|
4
|
-
export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTenantRow, SeedUserRow, } from "./types.js";
|
|
4
|
+
export type { SeedMembershipRow, SeedMigration, SeedMigrationContext, SeedTemplateResourceFilter, SeedTemplateResourceRow, SeedTenantRow, SeedUserRow, } from "./types.js";
|
package/dist/es-ops/types.d.ts
CHANGED
|
@@ -51,6 +51,23 @@ export type SeedTenantRow = {
|
|
|
51
51
|
readonly name: string;
|
|
52
52
|
readonly tenantKey: string;
|
|
53
53
|
};
|
|
54
|
+
/** Read-shape of a template-resolver row as exposed to seeds. */
|
|
55
|
+
export type SeedTemplateResourceRow = {
|
|
56
|
+
readonly id: string;
|
|
57
|
+
readonly tenantId: string;
|
|
58
|
+
readonly slug: string;
|
|
59
|
+
readonly kind: string;
|
|
60
|
+
readonly locale: string;
|
|
61
|
+
readonly status: string;
|
|
62
|
+
};
|
|
63
|
+
export type SeedTemplateResourceFilter = {
|
|
64
|
+
/** Defaults to the system tenant, where seeded system templates live. */
|
|
65
|
+
readonly tenantId?: TenantId;
|
|
66
|
+
readonly slug?: string;
|
|
67
|
+
readonly kind?: string;
|
|
68
|
+
readonly status?: string;
|
|
69
|
+
readonly locale?: string;
|
|
70
|
+
};
|
|
54
71
|
export type SeedMigrationContext = {
|
|
55
72
|
/** Event-Store-konformer Write via existing write-handler. System-User
|
|
56
73
|
* als Executor bypassed Access-Check (Standard-Seed-Pattern). Events
|
|
@@ -84,6 +101,9 @@ export type SeedMigrationContext = {
|
|
|
84
101
|
readonly findUserByEmail: (email: string) => Promise<SeedUserRow | null>;
|
|
85
102
|
readonly findMembershipsOfUser: (userId: string) => Promise<readonly SeedMembershipRow[]>;
|
|
86
103
|
readonly findTenants: () => Promise<readonly SeedTenantRow[]>;
|
|
104
|
+
/** Template rows ordered by slug, locale. Empty list when the
|
|
105
|
+
* template-resolver feature (its table) is not present. */
|
|
106
|
+
readonly findTemplateResources: (filter?: SeedTemplateResourceFilter) => Promise<readonly SeedTemplateResourceRow[]>;
|
|
87
107
|
/** Escape-Hatch — direkter DB-Zugang. Nur für READ-only Lookups die der
|
|
88
108
|
* Context nicht standard-mäßig anbietet. WRITES via systemWriteAs!
|
|
89
109
|
* Type ist DbRunner (Connection oder aktive Tx) weil der Runner den
|
|
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, type RebuildDeadL
|
|
|
9
9
|
export { toStoredEvent } from "./row-to-stored-event.js";
|
|
10
10
|
export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
|
|
11
11
|
export { createSnapshotsTable, type LoadAggregateWithSnapshotOptions, type LoadAggregateWithSnapshotResult, loadAggregateWithSnapshot, loadLatestSnapshot, type SaveSnapshotArgs, type Snapshot, type SnapshotReducer, saveSnapshot, snapshotsTable, } from "./snapshot.js";
|
|
12
|
+
export { type AppendEventInTenantDbOptions, appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
|
|
12
13
|
export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, type TransferAggregateStreamsArgs, transferAggregateStreams, } from "./transfer.js";
|
|
13
14
|
export { type EventUpcasters, makeUpcastCtx, type UpcasterErrorPolicy, type UpcastOptions, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
|
|
14
15
|
export { createUpcasterDeadLetterTable, type DeadLetterRow, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
|
|
@@ -9,6 +9,7 @@ export { createRebuildDeadLetterTable, listRebuildDeadLetters, rebuildDeadLetter
|
|
|
9
9
|
export { toStoredEvent } from "./row-to-stored-event.js";
|
|
10
10
|
export { assertSeedModeNotInProduction, isSeedModeEnabled, runSeedWritesAt, SEED_MODE_ENV, SeedModeDisabledError, } from "./seed-clock.js";
|
|
11
11
|
export { createSnapshotsTable, loadAggregateWithSnapshot, loadLatestSnapshot, saveSnapshot, snapshotsTable, } from "./snapshot.js";
|
|
12
|
+
export { appendEventInTenantDb, getStreamVersionInTenantDb, } from "./tenant-db-append.js";
|
|
12
13
|
export { AGGREGATE_TRANSFER_STREAM_TYPE, AGGREGATE_TRANSFERRED_EVENT_TYPE, transferAggregateStreams, } from "./transfer.js";
|
|
13
14
|
export { makeUpcastCtx, upcastStoredEvent, upcastStoredEvents, } from "./upcaster.js";
|
|
14
15
|
export { createUpcasterDeadLetterTable, listDeadLetters, recordUpcasterDeadLetter, upcasterDeadLetterTable, } from "./upcaster-dead-letter.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
2
|
+
import type { Registry } from "../engine/types/index.js";
|
|
3
|
+
import { type EventToAppend, type StoredEvent } from "./event-store.js";
|
|
4
|
+
export type AppendEventInTenantDbOptions = {
|
|
5
|
+
readonly registry?: Registry;
|
|
6
|
+
};
|
|
7
|
+
export declare function appendEventInTenantDb(tenantDb: TenantDb, event: Omit<EventToAppend, "tenantId">, options?: AppendEventInTenantDbOptions): Promise<StoredEvent>;
|
|
8
|
+
export declare function getStreamVersionInTenantDb(tenantDb: TenantDb, aggregateId: string): Promise<number>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
2
|
+
import { runProjectionsForEvent } from "../pipeline/projections-runner.js";
|
|
3
|
+
import { append, getStreamVersion } from "./event-store.js";
|
|
4
|
+
// Appends on the connection or transaction the TenantDb is bound to. The tenant is always the
|
|
5
|
+
// TenantDb's own, so a caller cannot write into another tenant's stream; a TenantDb the framework
|
|
6
|
+
// did not build (or a fail-closed systemScope guard) is rejected by tenantDbRunner.
|
|
7
|
+
export async function appendEventInTenantDb(tenantDb, event, options = {}) {
|
|
8
|
+
const runner = tenantDbRunner(tenantDb);
|
|
9
|
+
const stored = await append(runner, { ...event, tenantId: tenantDb.tenantId });
|
|
10
|
+
if (options.registry)
|
|
11
|
+
await runProjectionsForEvent(stored, options.registry, runner);
|
|
12
|
+
return stored;
|
|
13
|
+
}
|
|
14
|
+
export async function getStreamVersionInTenantDb(tenantDb, aggregateId) {
|
|
15
|
+
return getStreamVersion(tenantDbRunner(tenantDb), aggregateId, tenantDb.tenantId);
|
|
16
|
+
}
|
|
@@ -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);
|
|
@@ -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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.352.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>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.352.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.352.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.352.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.352.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "appendEventInTenantDb and getStreamVersionInTenantDb append and read stream versions through a TenantDb, optionally running inline projections",
|
|
6
|
+
"detail": "`appendEventInTenantDb` appends an event on a TenantDb's own connection or transaction\nFramework-side code that holds a `TenantDb` (for example inside `runInOwnTransaction`) could not append to the event store without a raw runner. `appendEventInTenantDb(tenantDb, event, { registry })` and `getStreamVersionInTenantDb(tenantDb, aggregateId)` from `@cosmicdrift/kumiko-framework/event-store` do it: the tenant is always the TenantDb's own, the append goes through `append()` (PII encryption, origin stamping), and with `registry` the inline projections run on the same runner."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.352.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Rate-limited, denied or invalid callers no longer consume cap usage or trigger soft-warn, because pre-handler gates run before reserveBeforeTransaction"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.352.0",
|
|
15
|
+
"type": "improvement",
|
|
16
|
+
"title": "reserveBeforeTransaction may return a ReservationHandle whose confirmInTransaction settles the reservation inside the handler transaction"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"version": "0.352.0",
|
|
20
|
+
"type": "improvement",
|
|
21
|
+
"title": "Seeds can find template rows with ctx.findTemplateResources",
|
|
22
|
+
"detail": "Seeds can replace a raw SELECT … FROM read_template_resources with ctx.findTemplateResources, which binds every filter value as a parameter."
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"version": "0.352.0",
|
|
26
|
+
"type": "improvement",
|
|
27
|
+
"title": "Boot validation and the client schema cover select optionsAvailabilityQuery on entity and screen fields"
|
|
28
|
+
},
|
|
2
29
|
{
|
|
3
30
|
"version": "0.349.0",
|
|
4
31
|
"type": "improvement",
|