@cosmicdrift/kumiko-framework 0.377.0 → 0.379.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/delivery-notifications.md +25 -2
- package/agents/examples/embedded.md +177 -0
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +5 -5
- package/agents/widgets.md +1 -0
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/entity-table-from-registry.d.ts +2 -1
- package/dist/db/entity-table-meta.d.ts +0 -2
- package/dist/db/entity-table-meta.js +1 -3
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +6 -0
- package/dist/db/executor-managed-tables.js +40 -0
- package/dist/db/index.d.ts +4 -2
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +3 -2
- package/dist/db/money.js +2 -7
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.js +28 -0
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.js +2 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +3 -0
- package/dist/db/tenant-db.js +54 -18
- package/dist/engine/boot-validator/entity-handler.js +12 -4
- package/dist/engine/boot-validator/form-derivations.d.ts +4 -0
- package/dist/engine/boot-validator/form-derivations.js +82 -0
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +50 -12
- package/dist/engine/build-config-feature-schema.js +33 -10
- package/dist/engine/embedded-derived.d.ts +1 -1
- package/dist/engine/embedded-derived.js +7 -1
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/form-derivations.d.ts +21 -0
- package/dist/engine/form-derivations.js +175 -0
- package/dist/engine/index.d.ts +4 -1
- package/dist/engine/index.js +2 -0
- package/dist/engine/minor-units.d.ts +2 -0
- package/dist/engine/minor-units.js +8 -0
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +2 -1
- package/dist/engine/template-variables.d.ts +2 -1
- package/dist/engine/template-variables.js +4 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/with-form-derivations.d.ts +9 -0
- package/dist/engine/with-form-derivations.js +16 -0
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/i18n/settings-hub-keys.js +1 -0
- package/dist/internal/testing.d.ts +2 -0
- package/dist/internal/testing.js +15 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/search/configure-tenant-index.d.ts +4 -0
- package/dist/search/configure-tenant-index.js +8 -0
- package/dist/search/index.d.ts +1 -0
- package/dist/search/index.js +1 -0
- package/dist/ui-types/index.d.ts +4 -2
- package/dist/ui-types/index.js +3 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +142 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- package/src/scripts/codemod/package-merge.ts +0 -11
|
@@ -5,16 +5,18 @@
|
|
|
5
5
|
// true buys an entity restore + trash (ctx.includeDeleted) + this cleanup, with
|
|
6
6
|
// no feature to wire.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
import { asEntityTableMeta,
|
|
8
|
+
// Each expired row is purged through the executor's forget verb: the `<entity>.forgotten`
|
|
9
|
+
// event replays on a projection rebuild, so the purged row stays gone. The earlier events stay
|
|
10
|
+
// in the log; irreversible event-log purging is data-retention's job (pruneEvents), a separate,
|
|
11
|
+
// consumer-lag-guarded path.
|
|
12
|
+
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
13
|
+
import { asEntityTableMeta, selectMany } from "../db/query.js";
|
|
14
14
|
import { hasFieldProperty } from "../db/table-introspection.js";
|
|
15
|
+
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
15
16
|
import { purgeExpiredFileRefsWithBytes } from "../files/purge-expired-file-ref-bytes.js";
|
|
16
17
|
import { FILE_REF_ENTITY_NAME } from "../files/types.js";
|
|
17
|
-
import { SYSTEM_USER_ID } from "./system-user.js";
|
|
18
|
+
import { createSystemUser, SYSTEM_USER_ID } from "./system-user.js";
|
|
19
|
+
import { SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
18
20
|
// qualifyEntityName convention (feature:type:kebab-name) with a reserved
|
|
19
21
|
// "soft-delete" owner — no real feature owns these; the framework synthesizes
|
|
20
22
|
// them. The job-runner keys cron scheduling off the name, the config-resolver
|
|
@@ -46,6 +48,10 @@ export function isSystemScopedSoftDeleteTable(table, entity) {
|
|
|
46
48
|
function isGlobalTenancyTable(table) {
|
|
47
49
|
return asEntityTableMeta(table)?.tenancy === "global";
|
|
48
50
|
}
|
|
51
|
+
async function resolveGraceDays(ctx, tenantId, db) {
|
|
52
|
+
const resolved = await ctx.configResolver?.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db);
|
|
53
|
+
return typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
|
|
54
|
+
}
|
|
49
55
|
export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
50
56
|
const { db, registry } = ctx;
|
|
51
57
|
if (!db || !registry) {
|
|
@@ -61,10 +67,7 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
61
67
|
// skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
|
-
const
|
|
65
|
-
? await ctx.configResolver.get(SOFT_DELETE_GRACE_DAYS_KEY, softDeleteGraceDaysConfig, tenantId, SYSTEM_USER_ID, db)
|
|
66
|
-
: undefined;
|
|
67
|
-
const graceDays = typeof resolved === "number" && resolved >= 1 ? resolved : DEFAULT_GRACE_DAYS;
|
|
70
|
+
const graceDays = await resolveGraceDays(ctx, tenantId, db);
|
|
68
71
|
const cutoff = Temporal.Now.instant().subtract({ hours: graceDays * 24 });
|
|
69
72
|
for (const proj of registry.getAllProjections().values()) {
|
|
70
73
|
if (proj.isImplicit !== true || typeof proj.source !== "string" || !proj.table)
|
|
@@ -80,23 +83,49 @@ export const softDeleteCleanupJob = async (_payload, ctx) => {
|
|
|
80
83
|
if (isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
81
84
|
continue;
|
|
82
85
|
const where = { isDeleted: true, deletedAt: { lt: cutoff }, tenantId };
|
|
83
|
-
|
|
86
|
+
const executor = createEventStoreExecutor(proj.table, entity, { entityName: proj.source });
|
|
87
|
+
await purgeExpiredRows({ db, executor, user: ctx.systemUser ?? createSystemUser(tenantId) }, proj.source, proj.table, where, cutoff, ctx);
|
|
84
88
|
}
|
|
85
89
|
};
|
|
86
|
-
|
|
90
|
+
const PURGE_PAGE_SIZE = 500;
|
|
91
|
+
async function forgetRow(writer, id) {
|
|
92
|
+
// ponytail: one append per expired row, batch when purge volume needs it
|
|
93
|
+
const result = await writer.executor.forget({ id }, writer.user, writer.db);
|
|
94
|
+
if (!result.isSuccess)
|
|
95
|
+
throw reraiseAsKumikoError(result.error);
|
|
96
|
+
}
|
|
97
|
+
async function purgeExpiredRows(writer, source, table, where, cutoff, ctx, readDb = writer.db) {
|
|
87
98
|
if (source === FILE_REF_ENTITY_NAME) {
|
|
88
99
|
// The row is the only record of the storageKey, so its bytes go first.
|
|
89
|
-
await purgeExpiredFileRefsWithBytes({
|
|
90
|
-
db,
|
|
100
|
+
return await purgeExpiredFileRefsWithBytes({
|
|
101
|
+
db: writer.db,
|
|
91
102
|
table,
|
|
92
103
|
where,
|
|
93
104
|
expiredBefore: cutoff,
|
|
94
105
|
files: ctx.files,
|
|
95
106
|
log: ctx.log,
|
|
107
|
+
forgetRow: (id) => forgetRow(writer, id),
|
|
96
108
|
});
|
|
97
109
|
}
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
await forgetExpiredPages(writer, source, table, where, ctx, readDb);
|
|
111
|
+
}
|
|
112
|
+
// Keyset on id: a row whose forget fails is logged and skipped, it must not hide the rows behind it.
|
|
113
|
+
async function forgetExpiredPages(writer, source, table, where, ctx, readDb) {
|
|
114
|
+
let afterId;
|
|
115
|
+
for (;;) {
|
|
116
|
+
const page = await selectMany(readDb, table, { ...where, ...(afterId === undefined ? {} : { id: { gt: afterId } }) }, { orderBy: { col: "id", direction: "asc" }, limit: PURGE_PAGE_SIZE });
|
|
117
|
+
const last = page[page.length - 1];
|
|
118
|
+
if (last === undefined)
|
|
119
|
+
break;
|
|
120
|
+
for (const row of page) {
|
|
121
|
+
try {
|
|
122
|
+
await forgetRow(writer, row.id);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
ctx.log?.warn(`[soft-delete-cleanup] ${source} ${row.id} not purged: ${err instanceof Error ? err.message : String(err)}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
afterId = last.id;
|
|
100
129
|
}
|
|
101
130
|
}
|
|
102
131
|
// System-global soft-deleted entities (tenancy "global" or no tenantId column) can't take a
|
|
@@ -119,13 +148,13 @@ export const softDeleteCleanupSystemJob = async (_payload, ctx) => {
|
|
|
119
148
|
if (!isSystemScopedSoftDeleteTable(proj.table, entity))
|
|
120
149
|
continue;
|
|
121
150
|
const where = { isDeleted: true, deletedAt: { lt: cutoff } };
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
const writer = {
|
|
152
|
+
db,
|
|
153
|
+
executor: createEventStoreExecutor(proj.table, entity, { entityName: proj.source }),
|
|
154
|
+
user: ctx.systemUser ?? createSystemUser(SYSTEM_TENANT_ID),
|
|
155
|
+
};
|
|
156
|
+
// A global-tenancy table is invisible to a tenant-mode ctx.db, so its candidates are read unscoped.
|
|
157
|
+
await purgeExpiredRows(writer, proj.source, proj.table, where, cutoff, ctx, isGlobalTenancyTable(proj.table) ? db.unsafeRaw() : db);
|
|
129
158
|
}
|
|
130
159
|
};
|
|
131
160
|
export function buildSoftDeleteCleanupJob() {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// must commit in the same TX as the aggregate-mutation that triggered
|
|
17
17
|
// it (stronger consistency than an async projection). Reviewer judges.
|
|
18
18
|
import { deleteMany } from "../../db/query.js";
|
|
19
|
+
import { withExecutorManagedWrites } from "../../db/tenant-db.js";
|
|
19
20
|
import { tenantDbWriteRunner } from "../../db/tenant-db-runner.js";
|
|
20
21
|
import { defineStep } from "../define-step.js";
|
|
21
22
|
import { resolveRequired } from "./_resolver-utils.js";
|
|
@@ -27,7 +28,7 @@ defineStep({
|
|
|
27
28
|
// The `unsafe*`-step IS the sanctioned direct projection write — it exists
|
|
28
29
|
// to bypass the executor deliberately (boot-validated allowlist + reviewer
|
|
29
30
|
// gate). The brand-strip cast makes that explicit at the one blessed seam.
|
|
30
|
-
await deleteMany(tenantDbWriteRunner(ctx.db, "unsafe projection delete"), args.table, where);
|
|
31
|
+
await deleteMany(withExecutorManagedWrites(tenantDbWriteRunner(ctx.db, "unsafe projection delete")), args.table, where);
|
|
31
32
|
},
|
|
32
33
|
});
|
|
33
34
|
export function buildUnsafeProjectionDeleteStep(args) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import type { Registry, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
|
|
2
|
+
import type { Registry, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariables } from "./types/index.ts";
|
|
3
3
|
type CommonOptions = {
|
|
4
4
|
readonly label?: string;
|
|
5
5
|
readonly required?: boolean;
|
|
@@ -26,6 +26,7 @@ export declare const templateVariable: {
|
|
|
26
26
|
readonly percent: (opts?: CommonOptions & {
|
|
27
27
|
readonly example?: number;
|
|
28
28
|
}) => TemplateScalarVariableDef;
|
|
29
|
+
readonly images: (opts?: CommonOptions) => TemplateImagesVariableDef;
|
|
29
30
|
readonly list: (item: Readonly<Record<string, TemplateScalarVariableDef>>, opts?: CommonOptions & {
|
|
30
31
|
readonly example?: readonly Readonly<Record<string, unknown>>[];
|
|
31
32
|
}) => TemplateListVariableDef;
|
|
@@ -17,6 +17,7 @@ export const templateVariable = {
|
|
|
17
17
|
...opts,
|
|
18
18
|
}),
|
|
19
19
|
percent: (opts = {}) => ({ type: "percent", ...opts }),
|
|
20
|
+
images: (opts = {}) => ({ type: "images", ...opts }),
|
|
20
21
|
list: (item, opts = {}) => ({ type: "list", item, ...opts }),
|
|
21
22
|
};
|
|
22
23
|
const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
|
|
@@ -53,7 +54,10 @@ function scalarToZod(def) {
|
|
|
53
54
|
return z.number().finite();
|
|
54
55
|
}
|
|
55
56
|
}
|
|
57
|
+
const imagesSchema = z.union([z.string(), z.array(z.string())]);
|
|
56
58
|
function variableToZod(def) {
|
|
59
|
+
if (def.type === "images")
|
|
60
|
+
return imagesSchema;
|
|
57
61
|
if (def.type !== "list")
|
|
58
62
|
return scalarToZod(def);
|
|
59
63
|
return z.array(z.object(Object.fromEntries(Object.entries(def.item).map(([name, item]) => [name, withOptionality(item)]))));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
2
|
-
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, MaxPerTenantOptions, NotificationDataFn, NotificationDefinition, NotificationLocalizedTemplate, NotificationRecipientFn, NotificationRecordMapping, NotificationTemplateBody, NotificationTemplateFn, NotificationTemplateMeta, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TemplateImagesVariableDef, TemplateListVariableDef, TemplateScalarVariableDef, TemplateVariableDef, TemplateVariables, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
3
3
|
export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
|
|
4
4
|
export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
5
5
|
export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
|
|
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
|
|
|
17
17
|
export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
|
|
18
18
|
export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionRowIdOf, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
|
|
19
19
|
export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
|
|
20
|
-
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
20
|
+
export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelGate, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormFieldDerivation, FormWidth, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RecordHeaderSubtitlePart, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
21
21
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
22
22
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
23
23
|
export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { type FormDerivationScreen } from "./form-derivations.ts";
|
|
3
|
+
import type { FormFieldDerivation } from "./types/screen.ts";
|
|
4
|
+
/** The `derive` a schema was wrapped with via `withFormDerivations`, so the
|
|
5
|
+
* boot validator can prove an actionForm's handler recomputes its totals. */
|
|
6
|
+
export declare function formDerivationsOfSchema(schema: unknown): Readonly<Record<string, FormFieldDerivation>> | undefined;
|
|
7
|
+
/** Server counterpart of an actionForm's `derive`: recomputes the derived head
|
|
8
|
+
* fields before the schema validates them, so client-sent totals never count. */
|
|
9
|
+
export declare function withFormDerivations<Schema extends z.ZodType>(schema: Schema, screen: FormDerivationScreen): z.ZodPreprocess<Schema, unknown>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { applyFormDerivations } from "./form-derivations.js";
|
|
3
|
+
const derivationsBySchema = new WeakMap();
|
|
4
|
+
/** The `derive` a schema was wrapped with via `withFormDerivations`, so the
|
|
5
|
+
* boot validator can prove an actionForm's handler recomputes its totals. */
|
|
6
|
+
export function formDerivationsOfSchema(schema) {
|
|
7
|
+
return schema instanceof z.ZodType ? derivationsBySchema.get(schema) : undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Server counterpart of an actionForm's `derive`: recomputes the derived head
|
|
10
|
+
* fields before the schema validates them, so client-sent totals never count. */
|
|
11
|
+
export function withFormDerivations(schema, screen) {
|
|
12
|
+
const wrapped = z.preprocess((value) => applyFormDerivations(value, screen), schema);
|
|
13
|
+
if (screen.derive !== undefined)
|
|
14
|
+
derivationsBySchema.set(wrapped, screen.derive);
|
|
15
|
+
return wrapped;
|
|
16
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { PgClient, PgListenClient } from "../db/connection.ts";
|
|
|
7
7
|
import type { AppContext, Registry } from "../engine/types/index.ts";
|
|
8
8
|
import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner.ts";
|
|
9
9
|
import type { Lifecycle } from "../lifecycle/index.ts";
|
|
10
|
-
import type {
|
|
10
|
+
import type { ObservabilityProvider } from "../observability/index.ts";
|
|
11
11
|
import type { Dispatcher, DispatcherOptions } from "../pipeline/dispatcher.ts";
|
|
12
12
|
import type { EventDedup, EventDispatcher } from "../pipeline/index.ts";
|
|
13
13
|
import type { SystemHooks } from "../pipeline/lifecycle-pipeline.ts";
|
|
@@ -17,8 +17,6 @@ export type BaseEntrypointOptions = {
|
|
|
17
17
|
readonly jwtSecret: string | JwtKeyring;
|
|
18
18
|
readonly jwtIssuer?: string;
|
|
19
19
|
readonly observability?: ObservabilityProvider;
|
|
20
|
-
/** @deprecated No effect: the HTTP route label carries no query string anymore, so there is nothing left to configure. */
|
|
21
|
-
readonly observabilityOptions?: ObservabilityOptions;
|
|
22
20
|
readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
23
21
|
readonly systemHooks?: SystemHooks;
|
|
24
22
|
readonly eventDedup?: EventDedup;
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const FrameworkReasons: {
|
|
|
16
16
|
readonly instructionFieldWriteRequiresHighRisk: "instruction_field_write_requires_high_risk";
|
|
17
17
|
readonly subjectErased: "subject_erased";
|
|
18
18
|
readonly fileRejected: "file_rejected";
|
|
19
|
+
readonly fileViolatesBindTarget: "file_violates_bind_target";
|
|
19
20
|
};
|
|
20
21
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
|
21
22
|
export declare const AgentReasons: {
|
package/dist/errors/reasons.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// framework-level reason (e.g. "is this a stale-state race? retry once").
|
|
5
5
|
//
|
|
6
6
|
// Features add their own local Reasons objects (see `samples/errors/` or
|
|
7
|
-
// `packages/bundled-features/src/tenant/constants.ts` → TenantErrors). The
|
|
7
|
+
// `packages/bundled-features/src/tenant/web/constants.ts` → TenantErrors). The
|
|
8
8
|
// framework deliberately does NOT enforce uniqueness across features — two
|
|
9
9
|
// features may use the same reason string if the semantics match. The
|
|
10
10
|
// convention: snake_case, no spaces, no feature prefix for framework reasons.
|
|
@@ -71,6 +71,9 @@ export const FrameworkReasons = {
|
|
|
71
71
|
// UnprocessableError: createFileRefFromBytes refused the file (size, type,
|
|
72
72
|
// content or attach target). `details.error` carries the upload-route error string.
|
|
73
73
|
fileRejected: "file_rejected",
|
|
74
|
+
// UnprocessableError: bindPendingFileRef({ bindTo }) found the pending upload outside the
|
|
75
|
+
// target field's accept/maxSize. `details.error` carries the validator's error string.
|
|
76
|
+
fileViolatesBindTarget: "file_violates_bind_target",
|
|
74
77
|
};
|
|
75
78
|
// Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
|
|
76
79
|
// sister catalog to FrameworkReasons instead of extending it — see the
|
|
@@ -12,6 +12,7 @@ export type ErrorResponseBody = {
|
|
|
12
12
|
readonly timestamp: string;
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
|
+
export declare const exposesCauseDetails: () => boolean;
|
|
15
16
|
export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
|
|
16
17
|
export type ErrorLogEntry = {
|
|
17
18
|
readonly name: string;
|
package/dist/errors/serialize.js
CHANGED
|
@@ -7,10 +7,14 @@ import { resolveErrorDocsUrl } from "./docs-url.js";
|
|
|
7
7
|
// possible — without this, a crash in a handler comes back as a bare
|
|
8
8
|
// "internal error" and developers have to add try/catch + UnprocessableError
|
|
9
9
|
// just to see what went wrong (real footgun, not theoretical). The exposure
|
|
10
|
-
// is gated on NODE_ENV
|
|
11
|
-
//
|
|
10
|
+
// is gated on NODE_ENV being explicitly "development" or "test": a missing or
|
|
11
|
+
// misspelled NODE_ENV (a misconfigured prod deploy) must not leak stack traces,
|
|
12
|
+
// paths or upstream URLs over the wire.
|
|
12
13
|
const CODES_WITHOUT_CLIENT_DETAILS = new Set(["internal_error"]);
|
|
13
|
-
const
|
|
14
|
+
export const exposesCauseDetails = () => {
|
|
15
|
+
const env = process.env["NODE_ENV"];
|
|
16
|
+
return env === "development" || env === "test";
|
|
17
|
+
};
|
|
14
18
|
function devCauseDetail(cause) {
|
|
15
19
|
if (!(cause instanceof Error))
|
|
16
20
|
return undefined;
|
|
@@ -21,7 +25,7 @@ function devCauseDetail(cause) {
|
|
|
21
25
|
};
|
|
22
26
|
}
|
|
23
27
|
export function serializeError(err, requestId, errorDocs) {
|
|
24
|
-
const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) &&
|
|
28
|
+
const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && !exposesCauseDetails();
|
|
25
29
|
const exposeDeclaredDetails = err.details !== undefined && !stripped;
|
|
26
30
|
// For internal_error in dev/test, surface the cause chain so the
|
|
27
31
|
// response body has something useful instead of "internal error".
|
|
@@ -30,7 +34,7 @@ export function serializeError(err, requestId, errorDocs) {
|
|
|
30
34
|
const detailsForResponse = (() => {
|
|
31
35
|
if (exposeDeclaredDetails)
|
|
32
36
|
return err.details;
|
|
33
|
-
if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) &&
|
|
37
|
+
if (CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && exposesCauseDetails()) {
|
|
34
38
|
return devCauseDetail(err.cause);
|
|
35
39
|
}
|
|
36
40
|
return undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NotFoundError, UnprocessableError } from "./classes.js";
|
|
2
2
|
import { KumikoError } from "./kumiko-error.js";
|
|
3
3
|
import { FrameworkReasons } from "./reasons.js";
|
|
4
|
+
import { exposesCauseDetails } from "./serialize.js";
|
|
4
5
|
import { buildInvalidTransitionDetails } from "./transition-details.js";
|
|
5
6
|
// Convenience for call sites that return a failed WriteResult. Keeps the
|
|
6
7
|
// pattern `return writeFailure(new NotFoundError(...))` compact so handlers
|
|
@@ -49,9 +50,9 @@ export function toWriteErrorInfo(err) {
|
|
|
49
50
|
// back to the client as a bare `{ message: "internal error" }` —
|
|
50
51
|
// identical user-experience to a misconfigured prod, just slower
|
|
51
52
|
// because the dev has to add try/catch + console.log to find the
|
|
52
|
-
// actual stack. Same gating as serializeError
|
|
53
|
+
// actual stack. Same gating as serializeError.
|
|
53
54
|
const causeDetails = err.details === undefined && err.code === "internal_error" && err.cause instanceof Error
|
|
54
|
-
?
|
|
55
|
+
? exposesCauseDetails()
|
|
55
56
|
? {
|
|
56
57
|
causeName: err.cause.name,
|
|
57
58
|
causeMessage: err.cause.message,
|
|
@@ -54,5 +54,12 @@ function extractIssueParams(issue) {
|
|
|
54
54
|
if (bag[key] !== undefined)
|
|
55
55
|
out[key] = bag[key];
|
|
56
56
|
}
|
|
57
|
+
// A custom issue that overrides its i18n key also carries the values the text interpolates.
|
|
58
|
+
if (issue.code === "custom" && typeof issue.params?.["i18nKey"] === "string") {
|
|
59
|
+
for (const [key, value] of Object.entries(issue.params)) {
|
|
60
|
+
if (key !== "i18nKey" && value !== undefined)
|
|
61
|
+
out[key] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
57
64
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
58
65
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.ts";
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, type PiiBackfillBatchOptions, type PiiBackfillBatchResult, type PiiBackfillFailure, type PiiBackfillOptions, type PiiBackfillResult, type PiiBackfillScanCache, } from "../db/queries/backfill-pii.ts";
|
|
3
|
+
export { type DeleteRowsWithoutReplayableStreamArgs, deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.ts";
|
|
3
4
|
export { type ArchiveStreamArgs, archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.ts";
|
|
4
5
|
export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.ts";
|
|
5
6
|
export { append, EVENTS_PUBSUB_CHANNEL, type EventMetadata, type EventToAppend, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, type StoredEvent, streamAllEventsByType, } from "./event-store.ts";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { APP_INSTANCE_STREAM_TYPE, APP_STARTED_EVENT_TYPE } from "../crypto/system-event-pii.js";
|
|
2
2
|
export { backfillEventPiiEncryption, backfillEventPiiEncryptionBatch, } from "../db/queries/backfill-pii.js";
|
|
3
|
+
export { deleteRowsWithoutReplayableStream, } from "../db/queries/event-store-orphan-rows.js";
|
|
3
4
|
export { archivedStreamsTable, archiveStream, createArchivedStreamsTable, isStreamArchived, restoreStream, } from "./archive.js";
|
|
4
5
|
export { ArchivedStreamError, IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
|
|
5
6
|
export { append, EVENTS_PUBSUB_CHANNEL, getEventsHighWaterMark, getStreamVersion, getStreamVersions, getUnscopedAggregateStreamMaxVersion, getUnscopedStreamMaxVersionForSeed, LOAD_ALL_EVENTS_ROW_LIMIT, loadAggregate, loadAggregateAsOf, loadAllEventsByType, loadEventsAfterVersion, streamAllEventsByType, } from "./event-store.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
|
|
2
|
+
export declare function isActionFormScreenQn(value: string): boolean;
|
|
3
|
+
/** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
|
|
4
|
+
export declare function resolveActionFormFileField(registry: Pick<Registry, "getScreen"> | undefined, screenQn: string, fieldName: string): AnyFileFieldDef | undefined;
|
|
5
|
+
export type ActionFormFileField = {
|
|
6
|
+
readonly screenQn: string;
|
|
7
|
+
readonly fieldName: string;
|
|
8
|
+
readonly fieldDef: AnyFileFieldDef;
|
|
9
|
+
};
|
|
10
|
+
export declare function listActionFormFileFields(registry: Pick<Registry, "getAllScreens"> | undefined): readonly ActionFormFileField[];
|
|
11
|
+
/**
|
|
12
|
+
* Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
|
|
13
|
+
* the access of its handler must both pass, so an upload is never accepted for a form the
|
|
14
|
+
* caller could not submit.
|
|
15
|
+
*/
|
|
16
|
+
export declare function mayUseActionForm(registry: Pick<Registry, "getScreen" | "getWriteHandler">, screenQn: string, user: Pick<SessionUser, "roles">): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { hasAccess } from "../engine/access.js";
|
|
2
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
3
|
+
// Same character set as the other upload identifiers: the screen QN is stored as the pending
|
|
4
|
+
// upload's entityType and compared by exact match, never interpolated into a storage key.
|
|
5
|
+
const ACTION_FORM_SCREEN_QN = /^[A-Za-z0-9_-]+:screen:[A-Za-z0-9_-]+$/;
|
|
6
|
+
export function isActionFormScreenQn(value) {
|
|
7
|
+
return ACTION_FORM_SCREEN_QN.test(value);
|
|
8
|
+
}
|
|
9
|
+
function findActionFormScreen(registry, screenQn) {
|
|
10
|
+
if (!isActionFormScreenQn(screenQn))
|
|
11
|
+
return undefined;
|
|
12
|
+
const screen = registry.getScreen(screenQn);
|
|
13
|
+
return screen?.type === "actionForm" ? screen : undefined;
|
|
14
|
+
}
|
|
15
|
+
/** The files/images field `fieldName` of the registered actionForm screen `screenQn`, if any. */
|
|
16
|
+
export function resolveActionFormFileField(registry, screenQn, fieldName) {
|
|
17
|
+
const screen = registry && findActionFormScreen(registry, screenQn);
|
|
18
|
+
const field = screen?.fields[fieldName];
|
|
19
|
+
return field !== undefined && isFileField(field) ? field : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function listActionFormFileFields(registry) {
|
|
22
|
+
const found = [];
|
|
23
|
+
for (const [screenQn, screen] of registry?.getAllScreens() ?? []) {
|
|
24
|
+
if (screen.type !== "actionForm")
|
|
25
|
+
continue;
|
|
26
|
+
for (const [fieldName, fieldDef] of Object.entries(screen.fields)) {
|
|
27
|
+
if (isFileField(fieldDef))
|
|
28
|
+
found.push({ screenQn, fieldName, fieldDef });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether `user` may submit the actionForm `screenQn`: the screen's own access (when set) and
|
|
35
|
+
* the access of its handler must both pass, so an upload is never accepted for a form the
|
|
36
|
+
* caller could not submit.
|
|
37
|
+
*/
|
|
38
|
+
export function mayUseActionForm(registry, screenQn, user) {
|
|
39
|
+
const screen = findActionFormScreen(registry, screenQn);
|
|
40
|
+
if (screen === undefined)
|
|
41
|
+
return false;
|
|
42
|
+
if (screen.access !== undefined && !hasAccess(user, screen.access))
|
|
43
|
+
return false;
|
|
44
|
+
return hasAccess(user, registry.getWriteHandler(screen.handler)?.access);
|
|
45
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { TenantDb } from "../db/tenant-db.ts";
|
|
|
2
2
|
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
|
|
3
3
|
import { type FileRefRows } from "./load-save-file-refs.ts";
|
|
4
4
|
export declare function fileRefIdsOf(value: unknown): readonly string[];
|
|
5
|
+
type FileRefRow = Readonly<Record<string, unknown>>;
|
|
5
6
|
export declare function bindPendingFileRefs(args: {
|
|
6
7
|
readonly registry: Registry;
|
|
7
8
|
readonly result: SaveContext;
|
|
@@ -24,6 +25,11 @@ type PendingFileRefLookup = {
|
|
|
24
25
|
readonly db: TenantDb;
|
|
25
26
|
};
|
|
26
27
|
export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
|
|
28
|
+
export type PendingFileRefBindTarget = {
|
|
29
|
+
readonly entityName: string;
|
|
30
|
+
readonly fieldName: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function assertRowFitsBindTarget(registry: Registry | undefined, bindTo: PendingFileRefBindTarget, row: FileRefRow): void;
|
|
27
33
|
export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
|
|
28
34
|
/**
|
|
29
35
|
* Binds one of the caller's own pending uploads to a record from a custom handler, with the
|
|
@@ -32,10 +38,18 @@ export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-confl
|
|
|
32
38
|
* The helper checks the upload, not the target: the caller must already be authorized to write
|
|
33
39
|
* `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
|
|
34
40
|
*
|
|
41
|
+
* `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
|
|
42
|
+
* the actionForm screen (entityName = the screen QN), and the handler decides which entity field
|
|
43
|
+
* keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
|
|
44
|
+
* (UnprocessableError otherwise); the field must be registered, which needs `registry`
|
|
45
|
+
* (the handler's `ctx.registry`), else InternalError.
|
|
46
|
+
*
|
|
35
47
|
* A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
|
|
36
48
|
* winner has committed); anything else throws.
|
|
37
49
|
*/
|
|
38
50
|
export declare function bindPendingFileRef(args: PendingFileRefLookup & {
|
|
39
51
|
readonly entityId: string;
|
|
52
|
+
readonly bindTo?: PendingFileRefBindTarget;
|
|
53
|
+
readonly registry?: Registry;
|
|
40
54
|
}): Promise<BindPendingFileRefOutcome>;
|
|
41
55
|
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
2
2
|
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
3
|
-
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
4
|
-
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
3
|
+
import { FrameworkReasons, InternalError, reraiseAsKumikoError, UnprocessableError, } from "../errors/index.js";
|
|
4
|
+
import { getFileRefExecutor, resolveAttachedField } from "./create-file-ref.js";
|
|
5
5
|
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
6
|
import { loadFileRefRows } from "./load-save-file-refs.js";
|
|
7
|
+
import { validateFile } from "./types.js";
|
|
7
8
|
export function fileRefIdsOf(value) {
|
|
8
9
|
if (typeof value === "string")
|
|
9
10
|
return isUuid(value) ? [value] : [];
|
|
@@ -21,10 +22,16 @@ function streamVersionOf(row) {
|
|
|
21
22
|
return typeof row["version"] === "number" ? row["version"] : undefined;
|
|
22
23
|
}
|
|
23
24
|
async function bindPendingRow(args) {
|
|
24
|
-
const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
|
|
25
|
+
const { row, fileRefId, entityName, fieldName, entityId, bindTo, user, db } = args;
|
|
25
26
|
if (!isPendingUploadFor(row, entityName, fieldName, user))
|
|
26
27
|
return { kind: "not-pending" };
|
|
27
|
-
const bound = await getFileRefExecutor().update({
|
|
28
|
+
const bound = await getFileRefExecutor().update({
|
|
29
|
+
id: fileRefId,
|
|
30
|
+
version: streamVersionOf(row),
|
|
31
|
+
changes: bindTo === undefined
|
|
32
|
+
? { entityId }
|
|
33
|
+
: { entityId, entityType: bindTo.entityName, fieldName: bindTo.fieldName },
|
|
34
|
+
}, user, db);
|
|
28
35
|
return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
|
|
29
36
|
}
|
|
30
37
|
// A form (or a server handler via createFileRefFromBytes) uploads before the
|
|
@@ -98,6 +105,31 @@ export async function loadPendingFileRef(lookup) {
|
|
|
98
105
|
}
|
|
99
106
|
return { id: lookup.fileRefId, storageKey, mimeType, size, version };
|
|
100
107
|
}
|
|
108
|
+
// The pending upload was checked against the form field it came in through; a different target
|
|
109
|
+
// field may be stricter. Metadata only (bytes are not re-read): the extension comes from the
|
|
110
|
+
// storage key, whose suffix is the sniffed type, because fileName may be encrypted personal data.
|
|
111
|
+
export function assertRowFitsBindTarget(registry, bindTo, row) {
|
|
112
|
+
const target = resolveAttachedField(registry, bindTo.entityName, bindTo.fieldName);
|
|
113
|
+
if (target.kind !== "resolved") {
|
|
114
|
+
throw new InternalError({
|
|
115
|
+
message: `bindPendingFileRef: bindTo ${bindTo.entityName}.${bindTo.fieldName} is not a registered file field (pass the handler's ctx.registry)`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const { storageKey, mimeType, size } = row;
|
|
119
|
+
if (typeof storageKey !== "string" || typeof mimeType !== "string" || typeof size !== "number") {
|
|
120
|
+
// A row without these can only come from corruption; letting it through would skip the check.
|
|
121
|
+
throw new InternalError({
|
|
122
|
+
message: `bindPendingFileRef: pending file_ref ${String(row["id"])} lacks storageKey, mimeType or size`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const error = validateFile({ fileName: storageKey, mimeType, size }, { maxSize: target.fieldDef.maxSize, accept: target.fieldDef.accept });
|
|
126
|
+
if (error !== null) {
|
|
127
|
+
throw new UnprocessableError(FrameworkReasons.fileViolatesBindTarget, {
|
|
128
|
+
i18nKey: "errors.file.violatesBindTarget",
|
|
129
|
+
details: { error },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
101
133
|
const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
|
|
102
134
|
/**
|
|
103
135
|
* Binds one of the caller's own pending uploads to a record from a custom handler, with the
|
|
@@ -106,6 +138,12 @@ const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
|
|
|
106
138
|
* The helper checks the upload, not the target: the caller must already be authorized to write
|
|
107
139
|
* `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
|
|
108
140
|
*
|
|
141
|
+
* `bindTo` moves the upload onto an entity file field of its own: a form upload is pending under
|
|
142
|
+
* the actionForm screen (entityName = the screen QN), and the handler decides which entity field
|
|
143
|
+
* keeps it. The row is re-tagged to `bindTo` and must fit that field's `accept`/`maxSize`
|
|
144
|
+
* (UnprocessableError otherwise); the field must be registered, which needs `registry`
|
|
145
|
+
* (the handler's `ctx.registry`), else InternalError.
|
|
146
|
+
*
|
|
109
147
|
* A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
|
|
110
148
|
* winner has committed); anything else throws.
|
|
111
149
|
*/
|
|
@@ -114,6 +152,8 @@ export async function bindPendingFileRef(args) {
|
|
|
114
152
|
// Without a stream version the update would run unversioned and could overwrite a concurrent bind.
|
|
115
153
|
if (row === undefined || streamVersionOf(row) === undefined)
|
|
116
154
|
return "not-pending";
|
|
155
|
+
if (args.bindTo !== undefined)
|
|
156
|
+
assertRowFitsBindTarget(args.registry, args.bindTo, row);
|
|
117
157
|
const outcome = await bindPendingRow({ ...args, row });
|
|
118
158
|
switch (outcome.kind) {
|
|
119
159
|
case "bound":
|
|
@@ -38,6 +38,9 @@ export type StoreFileInput = {
|
|
|
38
38
|
readonly entityType?: string | undefined;
|
|
39
39
|
readonly entityId?: string | undefined;
|
|
40
40
|
readonly fieldName?: string | undefined;
|
|
41
|
+
/** Qualified name of a registered actionForm screen; `fieldName` then names one of its files
|
|
42
|
+
* fields and the upload is stored as pending (entityType = the screen QN, no entityId). */
|
|
43
|
+
readonly actionForm?: string | undefined;
|
|
41
44
|
};
|
|
42
45
|
export type StoreFileDeps = {
|
|
43
46
|
readonly user: SessionUser;
|