@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/pii-leak-guard.js +3 -0
- package/dist/api/redis-sse-broker.d.ts +1 -0
- package/dist/api/redis-sse-broker.js +16 -10
- package/dist/api/request-context.d.ts +1 -0
- package/dist/api/request-context.js +5 -0
- package/dist/api/route-registrars.d.ts +2 -1
- package/dist/api/route-registrars.js +8 -0
- package/dist/api/routes.js +4 -2
- package/dist/api/server.js +39 -6
- package/dist/api/sse-broker.d.ts +7 -3
- package/dist/api/sse-route.js +7 -1
- package/dist/api/token-expiry-timer.d.ts +1 -0
- package/dist/api/token-expiry-timer.js +10 -0
- package/dist/bun-db/query.js +36 -4
- package/dist/compliance/sub-processors.js +5 -0
- package/dist/crypto/event-pii.js +4 -2
- package/dist/crypto/kms-wiring.js +31 -5
- package/dist/crypto/pii-field-encryption.d.ts +2 -3
- package/dist/crypto/pii-field-encryption.js +2 -3
- package/dist/db/eagerload.d.ts +8 -3
- package/dist/db/eagerload.js +85 -81
- package/dist/db/entity-table-meta.js +11 -0
- package/dist/db/event-store-executor-context.d.ts +12 -0
- package/dist/db/event-store-executor-context.js +78 -42
- package/dist/db/event-store-executor-read.js +67 -25
- package/dist/db/event-store-executor-write.js +54 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/migrate-generator.js +10 -2
- package/dist/db/queries/backfill-pii.d.ts +2 -0
- package/dist/db/queries/backfill-pii.js +24 -6
- package/dist/db/queries/event-consumer.d.ts +2 -3
- package/dist/db/queries/event-consumer.js +54 -43
- package/dist/db/queries/event-store.d.ts +4 -0
- package/dist/db/queries/event-store.js +40 -3
- package/dist/db/queries/pii-backfill-state.d.ts +8 -1
- package/dist/db/queries/pii-backfill-state.js +16 -5
- package/dist/db/queries/shadow-swap.js +37 -5
- package/dist/db/render-ddl.js +6 -6
- package/dist/db/table-builder.js +6 -7
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +47 -7
- package/dist/derivatives/variant-key.d.ts +1 -0
- package/dist/derivatives/variant-key.js +3 -0
- package/dist/engine/boot-validator/access-declarations.js +21 -1
- package/dist/engine/boot-validator/config-deps.js +3 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +53 -0
- package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
- package/dist/engine/boot-validator/handler-field-references.js +25 -0
- package/dist/engine/boot-validator/index.js +6 -1
- package/dist/engine/boot-validator/nav.js +3 -1
- package/dist/engine/boot-validator/ownership.d.ts +2 -1
- package/dist/engine/boot-validator/ownership.js +32 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -1
- package/dist/engine/boot-validator/query-output-columns.js +9 -9
- package/dist/engine/boot-validator/screens.js +136 -15
- package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
- package/dist/engine/boot-validator/select-option-tone.js +4 -0
- package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
- package/dist/engine/boot-validator/transfer-graph.js +8 -63
- package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
- package/dist/engine/build-app-schema.js +33 -2
- package/dist/engine/build-config-feature-schema.js +6 -0
- package/dist/engine/changeset-changes.d.ts +1 -0
- package/dist/engine/changeset-changes.js +49 -33
- package/dist/engine/dedupe-features.js +17 -1
- package/dist/engine/define-feature.js +11 -0
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/entity-handlers.js +70 -40
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +8 -0
- package/dist/engine/extension-names.d.ts +8 -9
- package/dist/engine/extension-names.js +8 -9
- package/dist/engine/extensions/tenant-data.d.ts +3 -1
- package/dist/engine/extensions/tenant-resource.d.ts +9 -1
- package/dist/engine/factories.d.ts +1 -1
- package/dist/engine/feature-builder-state.d.ts +2 -7
- package/dist/engine/feature-changelog.d.ts +2 -0
- package/dist/engine/feature-changelog.js +6 -0
- package/dist/engine/feature-config-events-jobs.js +44 -4
- package/dist/engine/feature-entity-handlers.js +1 -1
- package/dist/engine/handler-field-references.d.ts +3 -0
- package/dist/engine/handler-field-references.js +31 -0
- package/dist/engine/i18n-key.js +13 -3
- package/dist/engine/index.d.ts +9 -3
- package/dist/engine/index.js +7 -2
- package/dist/engine/ownership.js +3 -4
- package/dist/engine/pipeline.js +18 -1
- package/dist/engine/project-app-schema-for-roles.js +11 -3
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +8 -3
- package/dist/engine/role-assignment.d.ts +2 -0
- package/dist/engine/role-assignment.js +2 -0
- package/dist/engine/run-pipeline.js +12 -1
- package/dist/engine/schema-builder.js +8 -0
- package/dist/engine/screen-helpers.d.ts +13 -0
- package/dist/engine/screen-helpers.js +31 -0
- package/dist/engine/soft-delete-cleanup.js +3 -2
- package/dist/engine/steps/_read-source.d.ts +4 -0
- package/dist/engine/steps/_read-source.js +22 -0
- package/dist/engine/steps/describe-workflow-step-error.js +3 -1
- package/dist/engine/steps/read-find-many.js +2 -1
- package/dist/engine/steps/read-find-one.js +2 -1
- package/dist/engine/steps/workflow-step-error.d.ts +5 -0
- package/dist/engine/steps/workflow-step-error.js +13 -0
- package/dist/engine/transfer-adjacency.d.ts +24 -0
- package/dist/engine/transfer-adjacency.js +107 -0
- package/dist/engine/where-rule-lint.js +6 -3
- package/dist/entrypoint/index.d.ts +2 -0
- package/dist/entrypoint/index.js +2 -0
- package/dist/env/index.d.ts +4 -0
- package/dist/env/index.js +49 -23
- package/dist/errors/classes.d.ts +8 -0
- package/dist/errors/classes.js +13 -0
- package/dist/errors/index.d.ts +2 -1
- package/dist/errors/index.js +2 -1
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +3 -0
- package/dist/errors/subject-erased.d.ts +2 -0
- package/dist/errors/subject-erased.js +9 -0
- package/dist/es-ops/context.js +19 -1
- package/dist/event-store/admin-api.js +14 -10
- package/dist/event-store/event-store.js +11 -9
- package/dist/event-store/events-schema.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +30 -6
- package/dist/files/file-ref-entity.d.ts +6 -0
- package/dist/files/file-ref-table.d.ts +6 -0
- package/dist/files/file-routes.js +13 -7
- package/dist/files/resolve-content-type.d.ts +2 -0
- package/dist/files/resolve-content-type.js +8 -0
- package/dist/files/types.js +21 -1
- package/dist/i18n/required-surface-keys.d.ts +2 -0
- package/dist/i18n/required-surface-keys.js +8 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +13 -0
- package/dist/jobs/job-runner.js +128 -22
- package/dist/migrations/pii-event-backfill.js +46 -25
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-wiring.d.ts +1 -1
- package/dist/observability/metrics-wiring.js +2 -2
- package/dist/observability/standard-metrics.d.ts +4 -0
- package/dist/observability/standard-metrics.js +12 -0
- package/dist/pipeline/dispatch-shared.d.ts +2 -0
- package/dist/pipeline/dispatch-shared.js +74 -9
- package/dist/pipeline/dispatch-stream.d.ts +5 -1
- package/dist/pipeline/dispatch-stream.js +19 -12
- package/dist/pipeline/dispatcher.d.ts +3 -1
- package/dist/pipeline/dispatcher.js +10 -15
- package/dist/pipeline/event-consumer-state.js +0 -26
- package/dist/pipeline/event-dispatcher-admin.js +17 -11
- package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -2
- package/dist/pipeline/event-dispatcher.js +48 -7
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +25 -5
- package/dist/pipeline/member-reader.js +1 -0
- package/dist/pipeline/msp-rebuild.js +3 -3
- package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
- package/dist/pipeline/pending-gap-ranges.js +22 -0
- package/dist/pipeline/system-hooks.js +4 -4
- package/dist/pipeline/system-identity-switch.d.ts +1 -1
- package/dist/pipeline/system-identity-switch.js +5 -4
- package/dist/pipeline/tenant-timezone-cache.js +5 -3
- package/dist/redis/cache-sync-topics.d.ts +1 -0
- package/dist/redis/cache-sync-topics.js +1 -0
- package/dist/redis/client.d.ts +1 -0
- package/dist/redis/client.js +7 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/redis/pubsub-signal.d.ts +1 -0
- package/dist/redis/pubsub-signal.js +3 -0
- package/dist/search/meilisearch-adapter.js +34 -4
- package/dist/stack/test-stack.d.ts +4 -1
- package/dist/stack/test-stack.js +9 -2
- package/dist/testing/e2e-generator.js +7 -2
- package/dist/testing/shared-entities.d.ts +10 -0
- package/dist/ui-types/index.d.ts +3 -3
- package/dist/ui-types/index.js +2 -2
- package/dist/upgrade-cli.js +7 -2
- package/package.json +12 -4
- package/src/changes.json +228 -0
- package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateChangelog } from "./feature-changelog.js";
|
|
2
2
|
const BLOCK_RE = /<!--\s*kumiko-changes\s*\n([\s\S]*?)\n\s*-->/g;
|
|
3
3
|
const TYPE_VALUES = new Set(["breaking", "improvement", "fix"]);
|
|
4
|
-
const KEY_RE = /^(feature|type|title|detail|migration|codemod):(?:\s(.*))?$/;
|
|
4
|
+
const KEY_RE = /^(feature|type|title|detail|migration|codemod|manualAfterCodemod):(?:\s(.*))?$/;
|
|
5
5
|
function requiredField(fields, field, source) {
|
|
6
6
|
const value = fields.get(field)?.trim();
|
|
7
7
|
if (!value)
|
|
@@ -70,6 +70,53 @@ function assertBlockNotClosedEarly(markdown, match, source) {
|
|
|
70
70
|
throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
function resolveTitleAndDetail(fields, prose, source) {
|
|
74
|
+
const explicitTitle = fields.get("title")?.trim();
|
|
75
|
+
const title = explicitTitle || prose[0];
|
|
76
|
+
if (!title)
|
|
77
|
+
throw new Error(`${source}: kumiko-changes title is required`);
|
|
78
|
+
// The first prose line is the title's source only when no title was given or
|
|
79
|
+
// the explicit one restates it; otherwise it is genuine detail.
|
|
80
|
+
const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
|
|
81
|
+
const detail = fields.get("detail")?.trim() ||
|
|
82
|
+
prose
|
|
83
|
+
.slice(firstLineIsTitle ? 1 : 0)
|
|
84
|
+
.join("\n")
|
|
85
|
+
.trim();
|
|
86
|
+
return { title, detail };
|
|
87
|
+
}
|
|
88
|
+
function parseManualAfterCodemod(fields, source) {
|
|
89
|
+
const raw = fields.get("manualAfterCodemod")?.trim();
|
|
90
|
+
if (raw !== undefined && raw !== "true" && raw !== "false") {
|
|
91
|
+
throw new Error(`${source}: kumiko-changes manualAfterCodemod must be true or false`);
|
|
92
|
+
}
|
|
93
|
+
return raw === "true";
|
|
94
|
+
}
|
|
95
|
+
function buildChange(fields, prose, source) {
|
|
96
|
+
const feature = requiredField(fields, "feature", source);
|
|
97
|
+
const rawType = requiredField(fields, "type", source);
|
|
98
|
+
if (!TYPE_VALUES.has(rawType)) {
|
|
99
|
+
throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
|
|
100
|
+
}
|
|
101
|
+
const { title, detail } = resolveTitleAndDetail(fields, prose, source);
|
|
102
|
+
const migration = fields.get("migration")?.trim();
|
|
103
|
+
const codemod = fields.get("codemod")?.trim();
|
|
104
|
+
const manualAfterCodemod = parseManualAfterCodemod(fields, source);
|
|
105
|
+
const change = {
|
|
106
|
+
feature,
|
|
107
|
+
type: rawType,
|
|
108
|
+
title,
|
|
109
|
+
...(detail ? { detail } : {}),
|
|
110
|
+
...(migration ? { migration } : {}),
|
|
111
|
+
...(codemod ? { codemod } : {}),
|
|
112
|
+
...(manualAfterCodemod ? { manualAfterCodemod: true } : {}),
|
|
113
|
+
source,
|
|
114
|
+
};
|
|
115
|
+
const validation = validateChangelog({ version: "0.0.0", ...change });
|
|
116
|
+
if (validation.length > 0)
|
|
117
|
+
throw new Error(`${source}: ${validation.join("; ")}`);
|
|
118
|
+
return change;
|
|
119
|
+
}
|
|
73
120
|
/** Parse structured upgrade metadata embedded in a Changeset body. */
|
|
74
121
|
export function parseChangesetChanges(markdown, source) {
|
|
75
122
|
const changes = [];
|
|
@@ -85,38 +132,7 @@ export function parseChangesetChanges(markdown, source) {
|
|
|
85
132
|
assertBlockNotClosedEarly(markdown, match, source);
|
|
86
133
|
if (!block)
|
|
87
134
|
throw new Error(`${source}: empty kumiko-changes block`);
|
|
88
|
-
const
|
|
89
|
-
const feature = requiredField(fields, "feature", source);
|
|
90
|
-
const rawType = requiredField(fields, "type", source);
|
|
91
|
-
if (!TYPE_VALUES.has(rawType)) {
|
|
92
|
-
throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
|
|
93
|
-
}
|
|
94
|
-
const explicitTitle = fields.get("title")?.trim();
|
|
95
|
-
const title = explicitTitle || prose[0];
|
|
96
|
-
if (!title)
|
|
97
|
-
throw new Error(`${source}: kumiko-changes title is required`);
|
|
98
|
-
// The first prose line is the title's source only when no title was given or
|
|
99
|
-
// the explicit one restates it; otherwise it is genuine detail.
|
|
100
|
-
const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
|
|
101
|
-
const detail = fields.get("detail")?.trim() ||
|
|
102
|
-
prose
|
|
103
|
-
.slice(firstLineIsTitle ? 1 : 0)
|
|
104
|
-
.join("\n")
|
|
105
|
-
.trim();
|
|
106
|
-
const migration = fields.get("migration")?.trim();
|
|
107
|
-
const codemod = fields.get("codemod")?.trim();
|
|
108
|
-
const change = {
|
|
109
|
-
feature,
|
|
110
|
-
type: rawType,
|
|
111
|
-
title,
|
|
112
|
-
...(detail ? { detail } : {}),
|
|
113
|
-
...(migration ? { migration } : {}),
|
|
114
|
-
...(codemod ? { codemod } : {}),
|
|
115
|
-
source,
|
|
116
|
-
};
|
|
117
|
-
const validation = validateChangelog({ version: "0.0.0", ...change });
|
|
118
|
-
if (validation.length > 0)
|
|
119
|
-
throw new Error(`${source}: ${validation.join("; ")}`);
|
|
135
|
+
const change = buildChange(parseBlock(block, source), prose, source);
|
|
120
136
|
changes.push(change);
|
|
121
137
|
match = BLOCK_RE.exec(markdown);
|
|
122
138
|
}
|
|
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
|
|
|
6
6
|
}
|
|
7
7
|
return true;
|
|
8
8
|
}
|
|
9
|
+
function sameKeys(a, b) {
|
|
10
|
+
const aKeys = Object.keys(a ?? {});
|
|
11
|
+
const bKeys = new Set(Object.keys(b ?? {}));
|
|
12
|
+
return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
|
|
13
|
+
}
|
|
14
|
+
// dedupeOptions is a declaration, not proof: two same-named features that both
|
|
15
|
+
// carry `{}` but register different surface must still clash, not silently drop one.
|
|
16
|
+
function sameRegisteredSurface(a, b) {
|
|
17
|
+
return (sameKeys(a.entities, b.entities) &&
|
|
18
|
+
sameKeys(a.writeHandlers, b.writeHandlers) &&
|
|
19
|
+
sameKeys(a.queryHandlers, b.queryHandlers) &&
|
|
20
|
+
sameKeys(a.streamHandlers, b.streamHandlers));
|
|
21
|
+
}
|
|
9
22
|
function isInterchangeable(existing, next) {
|
|
10
23
|
if (existing === next)
|
|
11
24
|
return true;
|
|
12
25
|
if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
|
|
13
|
-
return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions)
|
|
26
|
+
return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
|
|
27
|
+
sameRegisteredSurface(existing, next));
|
|
14
28
|
}
|
|
15
29
|
return false;
|
|
16
30
|
}
|
|
17
31
|
// First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
|
|
32
|
+
// An app holding its own handle to a later, dropped instance would bind state the registry never
|
|
33
|
+
// runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
|
|
18
34
|
export function dedupeFeatures(features) {
|
|
19
35
|
const byName = new Map();
|
|
20
36
|
const result = [];
|
|
@@ -22,7 +22,18 @@ import { unwrapArrayForm } from "./object-form.js";
|
|
|
22
22
|
function resolveFeatureNamesArgs(args) {
|
|
23
23
|
return unwrapArrayForm(args, "features");
|
|
24
24
|
}
|
|
25
|
+
// dedupe-features compares options with Object.is per key, so a nested object
|
|
26
|
+
// would make two structurally identical mounts look different and fail the boot
|
|
27
|
+
// with an unobvious "mounted twice with different options".
|
|
28
|
+
function assertDedupeOptionsComparable(featureName, options) {
|
|
29
|
+
for (const [key, value] of Object.entries(options ?? {})) {
|
|
30
|
+
if (typeof value === "object" && value !== null) {
|
|
31
|
+
throw new Error(`[Feature ${featureName}] dedupeOptions.${key} must be a primitive or a stable function reference; dedupeOptions are compared shallowly, so a nested object never matches a second mount`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
25
35
|
export function defineFeature(name, setup, settings) {
|
|
36
|
+
assertDedupeOptionsComparable(name, settings?.dedupeOptions);
|
|
26
37
|
const state = createInitialFeatureBuilderState();
|
|
27
38
|
const registrar = {
|
|
28
39
|
systemScope() {
|
|
@@ -4,8 +4,6 @@ import { type EntityTable } from "../db/table-builder.js";
|
|
|
4
4
|
import type { EntityCrudRegistrar, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions } from "./types/entity-handlers.js";
|
|
5
5
|
import type { AccessRule, EntityDefinition, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "./types/index.js";
|
|
6
6
|
export type { EntityCrudHandlerDefaults, EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "./types/entity-handlers.js";
|
|
7
|
-
export declare function isDeprecatedCrossTenantHandler(handler: object): boolean;
|
|
8
|
-
export declare const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
|
|
9
7
|
export declare const MAX_LIST_LIMIT = 200;
|
|
10
8
|
export declare const entityListSchema: z.ZodObject<{
|
|
11
9
|
cursor: z.ZodOptional<z.ZodString>;
|
|
@@ -60,7 +58,9 @@ export declare function createEntityExecutor(entityName: string, entity: EntityD
|
|
|
60
58
|
};
|
|
61
59
|
export declare function defineProjectionQueryHandler(name: string, projectionQualifiedName: string, options: {
|
|
62
60
|
access: AccessRule;
|
|
63
|
-
unsafeAllTenants?: boolean
|
|
61
|
+
unsafeAllTenants?: boolean | {
|
|
62
|
+
readonly reason: string;
|
|
63
|
+
};
|
|
64
64
|
escapeHatch?: EscapeHatchDeclaration;
|
|
65
65
|
}): QueryHandlerDef;
|
|
66
66
|
/** Register standard entity CRUD handlers in one call. Access stays explicit — no openToAll default. */
|
|
@@ -3,13 +3,15 @@ import * as z from "zod";
|
|
|
3
3
|
import { collectReferenceFields, enrichRowWithReferences, enrichWithReferences, } from "../db/eagerload.js";
|
|
4
4
|
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
5
5
|
import { buildEntityTable } from "../db/table-builder.js";
|
|
6
|
-
import { acknowledgeConventionCrossTenant } from "../db/tenant-db.js";
|
|
6
|
+
import { acknowledgeConventionCrossTenant, reportConventionCrossTenantUse, } from "../db/tenant-db.js";
|
|
7
7
|
import { isSystemIdentity } from "../pipeline/system-identity-switch.js";
|
|
8
8
|
import { assertUnreachable } from "../utils/index.js";
|
|
9
9
|
import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler.js";
|
|
10
|
+
import { codemodPlaceholderReasonProblem } from "./escape-hatch-reason.js";
|
|
10
11
|
import { dropEmptyWriteOnlyValues } from "./field-access.js";
|
|
11
12
|
import { instructionFieldNames } from "./instruction-fields.js";
|
|
12
13
|
import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
14
|
+
import { parseTenantId } from "./types/identifiers.js";
|
|
13
15
|
// Convention-based handler factories for event-sourced aggregates.
|
|
14
16
|
//
|
|
15
17
|
// You register one handler per call (no auto-generation of a whole CRUD set),
|
|
@@ -46,26 +48,27 @@ import { buildInsertSchema, buildUpdateSchema } from "./schema-builder.js";
|
|
|
46
48
|
const WRITE_VERBS = ["create", "update", "delete", "restore"];
|
|
47
49
|
const QUERY_VERBS = ["list", "detail"];
|
|
48
50
|
const idSchema = z.object({ id: z.uuid() });
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const SYSTEM_ADMIN_ROLE = "SystemAdmin";
|
|
52
|
+
// A write handler with escapeHatch reads/writes rows of every tenant, so only an operator
|
|
53
|
+
// role may reach it — otherwise any tenant-level role holder could mutate foreign tenants.
|
|
54
|
+
function isSystemAdminOnly(access) {
|
|
55
|
+
return ("roles" in access &&
|
|
56
|
+
access.roles.length > 0 &&
|
|
57
|
+
access.roles.every((role) => role === SYSTEM_ADMIN_ROLE));
|
|
53
58
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
function resolveCrossTenantReason(name, options, kind) {
|
|
60
|
+
if (options.escapeHatch === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (options.escapeHatch.reason.trim().length === 0) {
|
|
63
|
+
throw new Error(`"${name}": escapeHatch requires a non-empty reason.`);
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
if (options.crossTenant === true) {
|
|
66
|
-
return `deprecated crossTenant: true on entity convention handler ${name}`;
|
|
65
|
+
const placeholderProblem = codemodPlaceholderReasonProblem(options.escapeHatch.reason);
|
|
66
|
+
if (placeholderProblem !== undefined)
|
|
67
|
+
throw new Error(`"${name}": ${placeholderProblem}`);
|
|
68
|
+
if (kind === "write" && !isSystemAdminOnly(options.access)) {
|
|
69
|
+
throw new Error(`"${name}": a write handler with escapeHatch reaches across every tenant, so its access must be SystemAdmin-only ({ roles: ["${SYSTEM_ADMIN_ROLE}"] }); got ${JSON.stringify(options.access)}.`);
|
|
67
70
|
}
|
|
68
|
-
return
|
|
71
|
+
return options.escapeHatch.reason;
|
|
69
72
|
}
|
|
70
73
|
// Upper bound on entity-list `limit`: unbounded/fractional values ran
|
|
71
74
|
// straight into the raw `LIMIT ${limit}` SQL string (event-store-executor-
|
|
@@ -194,29 +197,43 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
194
197
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
195
198
|
// TenantDb for these handlers before the ctx.db cutover.
|
|
196
199
|
//
|
|
197
|
-
// crossTenantReason (escapeHatch
|
|
200
|
+
// crossTenantReason (escapeHatch) mirrors the
|
|
198
201
|
// query-handler branch below: this ONE write handler reads/writes across
|
|
199
202
|
// every tenant without making the whole feature r.systemScope().
|
|
200
|
-
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
203
|
+
const crossTenantReason = resolveCrossTenantReason(name, options, "write");
|
|
204
|
+
// create always writes into the acting user's own tenant, so its FK,
|
|
205
|
+
// unique and preSave lookups must stay tenant-filtered even when the
|
|
206
|
+
// handler declares cross-tenant access for the row-addressing verbs.
|
|
201
207
|
const dbFor = (ctx) => {
|
|
202
208
|
if (ctx.systemDb) {
|
|
203
209
|
return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
|
|
204
210
|
}
|
|
205
|
-
return crossTenantReason !== undefined
|
|
206
|
-
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason
|
|
211
|
+
return crossTenantReason !== undefined && verb !== "create"
|
|
212
|
+
? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason, {
|
|
213
|
+
deferReport: reportsPerTouchedRow(ctx),
|
|
214
|
+
})
|
|
207
215
|
: ctx.db;
|
|
208
216
|
};
|
|
217
|
+
// actingUserFor reads the row it is about to touch anyway, so for these calls it reports the
|
|
218
|
+
// cross-tenant use per row (with that row as target) instead of dbFor reporting once up front.
|
|
219
|
+
const reportsPerTouchedRow = (ctx) => ctx.systemDb === undefined && crossTenantReason !== undefined && !entity.systemStream;
|
|
209
220
|
// The event stream is keyed by the acting user's tenantId (streamTenantFor in
|
|
210
221
|
// event-store-executor-context.ts), not by the db handed to the executor — an
|
|
211
222
|
// unfiltered "system"-mode db alone reads the foreign row but appends to an
|
|
212
223
|
// empty stream in the operator's own tenant, surfacing as a bogus
|
|
213
224
|
// version_conflict. Rewriting the acting tenant also keeps PII envelope keys
|
|
214
225
|
// derived from the row's tenant rather than the operator's.
|
|
215
|
-
const actingUserFor = async (user, id, db) => {
|
|
226
|
+
const actingUserFor = async (user, id, db, ctx) => {
|
|
216
227
|
if (crossTenantReason === undefined || entity.systemStream)
|
|
217
228
|
return user;
|
|
218
229
|
const row = await db.fetchOne(table, { id });
|
|
219
230
|
const rowTenantId = row?.["tenantId"];
|
|
231
|
+
if (reportsPerTouchedRow(ctx)) {
|
|
232
|
+
const targetTenantId = parseTenantId(rowTenantId);
|
|
233
|
+
reportConventionCrossTenantUse(db, targetTenantId !== null && typeof id === "string"
|
|
234
|
+
? { id, tenantId: targetTenantId }
|
|
235
|
+
: undefined);
|
|
236
|
+
}
|
|
220
237
|
if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId)
|
|
221
238
|
return user;
|
|
222
239
|
return { ...user, tenantId: rowTenantId };
|
|
@@ -255,7 +272,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
255
272
|
...submitted,
|
|
256
273
|
changes: dropEmptyWriteOnlyValues(entity, submitted.changes),
|
|
257
274
|
};
|
|
258
|
-
const user = await actingUserFor(event.user, payload.id, db);
|
|
275
|
+
const user = await actingUserFor(event.user, payload.id, db, ctx);
|
|
259
276
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
260
277
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
261
278
|
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
@@ -274,7 +291,7 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
274
291
|
handler = async (event, ctx) => {
|
|
275
292
|
const db = dbFor(ctx);
|
|
276
293
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
277
|
-
const user = await actingUserFor(event.user, payload.id, db);
|
|
294
|
+
const user = await actingUserFor(event.user, payload.id, db, ctx);
|
|
278
295
|
return executor.delete(payload, user, db);
|
|
279
296
|
};
|
|
280
297
|
break;
|
|
@@ -283,15 +300,13 @@ export function defineEntityWriteHandler(name, entity, options) {
|
|
|
283
300
|
handler = async (event, ctx) => {
|
|
284
301
|
const db = dbFor(ctx);
|
|
285
302
|
const payload = event.payload; // @cast-boundary engine-payload
|
|
286
|
-
const user = await actingUserFor(event.user, payload.id, db);
|
|
303
|
+
const user = await actingUserFor(event.user, payload.id, db, ctx);
|
|
287
304
|
return executor.restore(payload, user, db);
|
|
288
305
|
};
|
|
289
306
|
break;
|
|
290
307
|
default:
|
|
291
308
|
assertUnreachable(verb, "write verb");
|
|
292
309
|
}
|
|
293
|
-
if (options.crossTenant === true)
|
|
294
|
-
deprecatedCrossTenantHandlers.add(handler);
|
|
295
310
|
// escapeHatch stays off the def: WriteHandlerDef.escapeHatch also grants unsafeRaw, global writes, identity switches.
|
|
296
311
|
return {
|
|
297
312
|
name,
|
|
@@ -335,13 +350,13 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
335
350
|
// useReferenceLookup stays as a fallback (for apps that write custom
|
|
336
351
|
// handlers by hand without this wrapper).
|
|
337
352
|
const hasRefFields = collectReferenceFields(entity).length > 0;
|
|
338
|
-
const crossTenantReason = resolveCrossTenantReason(name, options);
|
|
353
|
+
const crossTenantReason = resolveCrossTenantReason(name, options, "query");
|
|
339
354
|
// Preference order:
|
|
340
355
|
// 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
|
|
341
356
|
// cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
|
|
342
357
|
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
343
358
|
// TenantDb for these handlers before that cutover.
|
|
344
|
-
// 2. crossTenantReason (escapeHatch
|
|
359
|
+
// 2. crossTenantReason (escapeHatch) — this
|
|
345
360
|
// ONE handler reads across every tenant (e.g. a SystemAdmin-only
|
|
346
361
|
// operator inspector) without making the whole feature r.systemScope()
|
|
347
362
|
// — that would drop tenant isolation from every OTHER handler the
|
|
@@ -386,8 +401,12 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
386
401
|
},
|
|
387
402
|
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
388
403
|
});
|
|
404
|
+
const eagerLoadViewer = {
|
|
405
|
+
user: query.user,
|
|
406
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
407
|
+
};
|
|
389
408
|
const enrichedRows = hasRefFields
|
|
390
|
-
? await enrichWithReferences(result.rows, entity, (name) => ctx.registry.getEntity(name), db)
|
|
409
|
+
? await enrichWithReferences(result.rows, entity, (name) => ctx.registry.getEntity(name), db, eagerLoadViewer)
|
|
391
410
|
: result.rows;
|
|
392
411
|
return { ...result, rows: augmentDerivedFields(enrichedRows, entity) };
|
|
393
412
|
};
|
|
@@ -401,14 +420,15 @@ export function defineEntityQueryHandler(name, entity, options) {
|
|
|
401
420
|
}); // @cast-boundary engine-payload
|
|
402
421
|
if (row === null || !hasRefFields)
|
|
403
422
|
return row;
|
|
404
|
-
return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db
|
|
423
|
+
return enrichRowWithReferences(row, entity, (name) => ctx.registry.getEntity(name), db, {
|
|
424
|
+
user: query.user,
|
|
425
|
+
parentVisibility: { entities: ctx.registry.getAllEntities() },
|
|
426
|
+
});
|
|
405
427
|
};
|
|
406
428
|
break;
|
|
407
429
|
default:
|
|
408
430
|
assertUnreachable(verb, "query verb");
|
|
409
431
|
}
|
|
410
|
-
if (options.crossTenant === true)
|
|
411
|
-
deprecatedCrossTenantHandlers.add(handler);
|
|
412
432
|
// escapeHatch stays off the def: QueryHandlerDef.escapeHatch also grants unsafeRaw and identity switches.
|
|
413
433
|
return {
|
|
414
434
|
name,
|
|
@@ -497,6 +517,10 @@ export function createEntityExecutor(entityName, entity) {
|
|
|
497
517
|
// For anything more involved (filters, joins, custom shaping), write the
|
|
498
518
|
// query-handler explicitly with ctx.queryProjection or a raw select.
|
|
499
519
|
export function defineProjectionQueryHandler(name, projectionQualifiedName, options) {
|
|
520
|
+
const escapeHatch = options.escapeHatch ??
|
|
521
|
+
(typeof options.unsafeAllTenants === "object"
|
|
522
|
+
? { reason: options.unsafeAllTenants.reason }
|
|
523
|
+
: undefined);
|
|
500
524
|
return {
|
|
501
525
|
name,
|
|
502
526
|
schema: z.object({}),
|
|
@@ -506,7 +530,7 @@ export function defineProjectionQueryHandler(name, projectionQualifiedName, opti
|
|
|
506
530
|
// pagination envelopes or added metadata.
|
|
507
531
|
handler: async (_query, ctx) => ctx.queryProjection(projectionQualifiedName, options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined), // @wrapper-known semantic-alias
|
|
508
532
|
access: options.access,
|
|
509
|
-
...(
|
|
533
|
+
...(escapeHatch && { escapeHatch }),
|
|
510
534
|
};
|
|
511
535
|
}
|
|
512
536
|
function defaultCrudVerbs(entity) {
|
|
@@ -539,18 +563,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
|
|
|
539
563
|
};
|
|
540
564
|
const resolveWriteOpts = (verb) => {
|
|
541
565
|
const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
|
|
566
|
+
const verbAgent = options?.agents?.[verb];
|
|
542
567
|
return {
|
|
543
568
|
...writeOpts,
|
|
544
569
|
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
545
570
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
571
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
546
572
|
...(excludeFields !== undefined && { excludeFields }),
|
|
547
573
|
};
|
|
548
574
|
};
|
|
549
|
-
const resolveReadOpts = (verb) =>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
575
|
+
const resolveReadOpts = (verb) => {
|
|
576
|
+
const verbAgent = options?.agents?.[verb];
|
|
577
|
+
return {
|
|
578
|
+
...readOpts,
|
|
579
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
580
|
+
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
581
|
+
...(verbAgent !== undefined && { agent: verbAgent }),
|
|
582
|
+
};
|
|
583
|
+
};
|
|
554
584
|
if (verbs.create) {
|
|
555
585
|
r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
|
|
556
586
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Shared by the migrate-cross-tenant codemod (which writes it) and the escapeHatch reason
|
|
2
|
+
// validation (which rejects it), so an unedited codemod reason can never reach a running app.
|
|
3
|
+
export const CODEMOD_PLACEHOLDER_REASON_MARKER = "state the operator use case here";
|
|
4
|
+
export function codemodPlaceholderReasonProblem(reason) {
|
|
5
|
+
if (!reason.includes(CODEMOD_PLACEHOLDER_REASON_MARKER))
|
|
6
|
+
return undefined;
|
|
7
|
+
return `the reason still contains the migrate-cross-tenant codemod placeholder ("${CODEMOD_PLACEHOLDER_REASON_MARKER}") — replace the codemod placeholder with the real operator use case`;
|
|
8
|
+
}
|
|
@@ -23,19 +23,18 @@ export declare const EXT_USER_DATA_ORDER: {
|
|
|
23
23
|
readonly DEFAULT: 0;
|
|
24
24
|
};
|
|
25
25
|
/**
|
|
26
|
-
* `tenantData` —
|
|
26
|
+
* `tenantData` — per-entity tenant-destroy hooks (GDPR + end of data processing agreement).
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
* - `destroy(tenantId, ctx) => Promise<void>`
|
|
28
|
+
* Expected hook method: `destroy(ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
30
29
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Registered by: `tenant-lifecycle`.
|
|
31
|
+
* Consumed by: every feature with a tenantId field.
|
|
33
32
|
*/
|
|
34
33
|
export declare const EXT_TENANT_DATA: "tenantData";
|
|
35
34
|
/**
|
|
36
35
|
* `storageProvider` — file-storage-plugin tenant-destroy hook.
|
|
37
36
|
*
|
|
38
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
37
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
39
38
|
*
|
|
40
39
|
* Registered by: `files-tenant-data`.
|
|
41
40
|
* Consumed by: pluggable providers (Local, MinIO, S3, R2).
|
|
@@ -113,7 +112,7 @@ export declare const EXT_DERIVATIVE_OVERLAY_RESOLVER: "derivativeOverlayResolver
|
|
|
113
112
|
* `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
|
|
114
113
|
* cleanup on tenant-destroy).
|
|
115
114
|
*
|
|
116
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
115
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
117
116
|
*
|
|
118
117
|
* Consumed by: Meilisearch and other search-adapter implementations.
|
|
119
118
|
*/
|
|
@@ -122,14 +121,14 @@ export declare const EXT_SEARCH_ADAPTER: "searchAdapter";
|
|
|
122
121
|
* `externalResource` — external-service tenant-destroy hook (webhook
|
|
123
122
|
* subscriptions, Brevo recipient lists, provider customer accounts).
|
|
124
123
|
*
|
|
125
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
124
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
126
125
|
*/
|
|
127
126
|
export declare const EXT_EXTERNAL_RESOURCE: "externalResource";
|
|
128
127
|
/**
|
|
129
128
|
* `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
|
|
130
129
|
* (custom domain, cert-manager issuer, dedicated pod/volume).
|
|
131
130
|
*
|
|
132
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
131
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
133
132
|
*/
|
|
134
133
|
export declare const EXT_INFRA_RESOURCE: "infraResource";
|
|
135
134
|
/**
|
|
@@ -42,19 +42,18 @@ export const EXT_USER_DATA_ORDER = {
|
|
|
42
42
|
DEFAULT: 0,
|
|
43
43
|
};
|
|
44
44
|
/**
|
|
45
|
-
* `tenantData` —
|
|
45
|
+
* `tenantData` — per-entity tenant-destroy hooks (GDPR + end of data processing agreement).
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
* - `destroy(tenantId, ctx) => Promise<void>`
|
|
47
|
+
* Expected hook method: `destroy(ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* Registered by: `tenant-lifecycle`.
|
|
50
|
+
* Consumed by: every feature with a tenantId field.
|
|
52
51
|
*/
|
|
53
52
|
export const EXT_TENANT_DATA = "tenantData";
|
|
54
53
|
/**
|
|
55
54
|
* `storageProvider` — file-storage-plugin tenant-destroy hook.
|
|
56
55
|
*
|
|
57
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
56
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
58
57
|
*
|
|
59
58
|
* Registered by: `files-tenant-data`.
|
|
60
59
|
* Consumed by: pluggable providers (Local, MinIO, S3, R2).
|
|
@@ -142,7 +141,7 @@ export const EXT_DERIVATIVE_OVERLAY_RESOLVER = "derivativeOverlayResolver";
|
|
|
142
141
|
* `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
|
|
143
142
|
* cleanup on tenant-destroy).
|
|
144
143
|
*
|
|
145
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
144
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
146
145
|
*
|
|
147
146
|
* Consumed by: Meilisearch and other search-adapter implementations.
|
|
148
147
|
*/
|
|
@@ -151,14 +150,14 @@ export const EXT_SEARCH_ADAPTER = "searchAdapter";
|
|
|
151
150
|
* `externalResource` — external-service tenant-destroy hook (webhook
|
|
152
151
|
* subscriptions, Brevo recipient lists, provider customer accounts).
|
|
153
152
|
*
|
|
154
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
153
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
155
154
|
*/
|
|
156
155
|
export const EXT_EXTERNAL_RESOURCE = "externalResource";
|
|
157
156
|
/**
|
|
158
157
|
* `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
|
|
159
158
|
* (custom domain, cert-manager issuer, dedicated pod/volume).
|
|
160
159
|
*
|
|
161
|
-
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
160
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<TenantDestroyHookResult | void>`.
|
|
162
161
|
*/
|
|
163
162
|
export const EXT_INFRA_RESOURCE = "infraResource";
|
|
164
163
|
/**
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
2
2
|
import type { TenantDb } from "../../db/tenant-db.js";
|
|
3
3
|
import type { Registry, TenantId } from "../types/index.js";
|
|
4
|
+
import type { TenantDestroyHookResult } from "./tenant-resource.js";
|
|
4
5
|
export interface TenantDataHookCtx {
|
|
5
6
|
readonly db: TenantDb;
|
|
6
7
|
readonly registry: Registry;
|
|
7
8
|
readonly tenantId: TenantId;
|
|
9
|
+
readonly deadlineAt?: number;
|
|
8
10
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
9
11
|
readonly log?: (message: string) => void;
|
|
10
12
|
}
|
|
11
|
-
export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
|
|
13
|
+
export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<TenantDestroyHookResult | void>;
|
|
12
14
|
export interface TenantDataExtensionHooks {
|
|
13
15
|
readonly destroy: TenantDataDestroyHook;
|
|
14
16
|
}
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
2
2
|
import type { DbRunner } from "../../db/connection.js";
|
|
3
3
|
import type { TenantId } from "../types/index.js";
|
|
4
|
+
export type TenantDestroyHookResult = {
|
|
5
|
+
readonly done: true;
|
|
6
|
+
readonly processed?: number;
|
|
7
|
+
} | {
|
|
8
|
+
readonly done: false;
|
|
9
|
+
readonly processed: number;
|
|
10
|
+
};
|
|
4
11
|
export interface TenantResourceHookCtx {
|
|
5
12
|
readonly tenantId: TenantId;
|
|
13
|
+
readonly deadlineAt?: number;
|
|
6
14
|
readonly db: DbRunner;
|
|
7
15
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
8
16
|
readonly log?: (message: string) => void;
|
|
9
17
|
}
|
|
10
|
-
export type TenantResourceDestroyHook = (tenantId: TenantId, ctx: TenantResourceHookCtx) => Promise<void>;
|
|
18
|
+
export type TenantResourceDestroyHook = (tenantId: TenantId, ctx: TenantResourceHookCtx) => Promise<TenantDestroyHookResult | void>;
|
|
11
19
|
export interface TenantResourceExtensionHooks {
|
|
12
20
|
readonly destroyTenant: TenantResourceDestroyHook;
|
|
13
21
|
}
|
|
@@ -35,7 +35,7 @@ export declare function createBooleanField<R extends true | false = false>(overr
|
|
|
35
35
|
};
|
|
36
36
|
export declare function createSelectField<const TOptions extends readonly string[], R extends true | false = false>(opts: {
|
|
37
37
|
options: TOptions;
|
|
38
|
-
} & Partial<Omit<SelectFieldDef<TOptions
|
|
38
|
+
} & Partial<Omit<SelectFieldDef<NoInfer<TOptions>>, "type" | "options" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind & {
|
|
39
39
|
required?: R;
|
|
40
40
|
}): SelectFieldDef<TOptions> & {
|
|
41
41
|
required: R;
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
2
2
|
import type * as z from "zod";
|
|
3
|
-
import type { ZodType } from "zod";
|
|
4
3
|
import type { HttpRouteDefinition } from "./types/http-route.js";
|
|
5
|
-
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
4
|
+
import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
|
|
6
5
|
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
|
|
7
6
|
import type { ScreenDefinition } from "./types/screen.js";
|
|
8
7
|
import type { WorkspaceDefinition } from "./types/workspace.js";
|
|
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
|
|
|
23
22
|
configKeys: Record<string, ConfigKeyDefinition>;
|
|
24
23
|
configSeeds: ConfigSeedDef[];
|
|
25
24
|
jobs: Record<string, JobDefinition>;
|
|
26
|
-
events: Record<string,
|
|
27
|
-
name: string;
|
|
28
|
-
schema: ZodType;
|
|
29
|
-
version: number;
|
|
30
|
-
}>;
|
|
25
|
+
events: Record<string, EventDef>;
|
|
31
26
|
eventMigrations: Record<string, EventMigrationDef[]>;
|
|
32
27
|
configReads: string[];
|
|
33
28
|
entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
|
|
@@ -8,6 +8,8 @@ export type ChangelogEntry = {
|
|
|
8
8
|
readonly migration?: string;
|
|
9
9
|
/** Path (relative to packages/framework/src/, under scripts/codemod/) run by `kumiko upgrade --apply`. */
|
|
10
10
|
readonly codemod?: string;
|
|
11
|
+
/** The codemod only covers part of the migration: the entry is also recorded as pending manual work after it ran. */
|
|
12
|
+
readonly manualAfterCodemod?: boolean;
|
|
11
13
|
};
|
|
12
14
|
export type FeatureChangelog = {
|
|
13
15
|
readonly feature: string;
|
|
@@ -33,6 +33,9 @@ function isChangelogEntry(value) {
|
|
|
33
33
|
return false;
|
|
34
34
|
if (typeof obj["title"] !== "string")
|
|
35
35
|
return false;
|
|
36
|
+
if (obj["manualAfterCodemod"] !== undefined && typeof obj["manualAfterCodemod"] !== "boolean") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
36
39
|
return true;
|
|
37
40
|
}
|
|
38
41
|
export function validateChangelog(entry) {
|
|
@@ -43,6 +46,9 @@ export function validateChangelog(entry) {
|
|
|
43
46
|
if (entry.type === "breaking" && entry.migration?.trim() === "") {
|
|
44
47
|
errors.push(`breaking change "${entry.title}" has empty migration field`);
|
|
45
48
|
}
|
|
49
|
+
if (entry.manualAfterCodemod && !entry.codemod) {
|
|
50
|
+
errors.push(`change "${entry.title}" sets manualAfterCodemod without a codemod field`);
|
|
51
|
+
}
|
|
46
52
|
return errors;
|
|
47
53
|
}
|
|
48
54
|
export function compareVersions(a, b) {
|