@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -13,4 +13,4 @@ export declare const bindReferencedFilesWrite: import("@cosmicdrift/kumiko-types
|
|
|
13
13
|
bound: number;
|
|
14
14
|
skipped: number;
|
|
15
15
|
nextAfterId: string | null;
|
|
16
|
-
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
|
|
16
|
+
}, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap, undefined>;
|
|
@@ -5,6 +5,7 @@ import { ValidationError } from "../errors/index.js";
|
|
|
5
5
|
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
6
6
|
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
7
7
|
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
8
|
+
import { loadFileRefRows } from "./load-save-file-refs.js";
|
|
8
9
|
import { findImplicitEntityTable } from "./record-write-check.js";
|
|
9
10
|
const MAX_PAGE_SIZE = 500;
|
|
10
11
|
const DEFAULT_PAGE_SIZE = 200;
|
|
@@ -50,40 +51,48 @@ function loadRecordPage(db, { entity, table }, { entityIds, afterId, limit }) {
|
|
|
50
51
|
: afterId !== undefined && { id: { gt: afterId } }),
|
|
51
52
|
}, { orderBy: { col: "id", direction: "asc" }, ...(entityIds === undefined && { limit }) });
|
|
52
53
|
}
|
|
53
|
-
// Unbound
|
|
54
|
-
//
|
|
54
|
+
// Unbound and same tenant. The record's creator may bind their own unclaimed or pending upload.
|
|
55
|
+
// A system-generated file carries no owner of its own, so it binds only where it is already
|
|
56
|
+
// pending for exactly this entity field — an unclaimed one could otherwise be pulled into any
|
|
57
|
+
// record that merely names its id.
|
|
55
58
|
function isBindableToRecord(fileRef, target) {
|
|
56
59
|
const uploader = fileRef["insertedById"];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
fileRef["
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
if (typeof uploader !== "string" ||
|
|
61
|
+
fileRef["tenantId"] !== target.tenantId ||
|
|
62
|
+
fileRef["entityId"] !== null) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
const isPendingForTarget = fileRef["entityType"] === target.entityType && fileRef["fieldName"] === target.fieldName;
|
|
66
|
+
if (uploader === target.record["insertedById"]) {
|
|
67
|
+
return fileRef["entityType"] === null || isPendingForTarget;
|
|
68
|
+
}
|
|
69
|
+
return uploader === SYSTEM_USER_ID && isPendingForTarget;
|
|
63
70
|
}
|
|
64
71
|
// The binding follows only the record's declared file fields; the caller names
|
|
65
72
|
// no file ids. The record's creator must have uploaded the file themselves (or the
|
|
66
|
-
// system generated it), otherwise a foreign reference would pull
|
|
67
|
-
// private upload into the referencing user's record and make it readable there.
|
|
73
|
+
// system generated it for exactly this field), otherwise a foreign reference would pull
|
|
74
|
+
// someone else's private upload into the referencing user's record and make it readable there.
|
|
68
75
|
export const bindReferencedFilesWrite = defineWriteHandler({
|
|
69
76
|
name: "bindReferencedFiles",
|
|
70
77
|
schema: payloadSchema,
|
|
71
78
|
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
72
79
|
// Maintenance backfill run by ops or system jobs, not a task an assistant performs.
|
|
73
80
|
agent: { expose: false },
|
|
74
|
-
description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
|
|
81
|
+
description: "Binds the files a record references through its declared file fields to that record, so readers of the record see them through record access. Covers files uploaded before pending-upload binding existed (entityId null) and records created without a save lifecycle (custom handlers, system jobs). Binds only files uploaded by the record's creator or generated by the system for exactly this field; skips files already bound elsewhere, uploaded by someone else, of another tenant or missing; never rebinds.",
|
|
75
82
|
handler: async (event, ctx) => {
|
|
76
83
|
const { entityType, entityIds, limit } = event.payload;
|
|
77
84
|
const { fileFieldNames, ...source } = resolveFileBearingEntity(ctx.registry, entityType);
|
|
78
85
|
const records = await loadRecordPage(ctx.db, source, event.payload);
|
|
86
|
+
const referencedIds = records.flatMap((record) => fileFieldNames.flatMap((fieldName) => fileRefIdsOf(record[fieldName])));
|
|
87
|
+
const fileRefRows = await loadFileRefRows(ctx.db, referencedIds, event.user.tenantId);
|
|
79
88
|
const executor = getFileRefExecutor();
|
|
80
89
|
let bound = 0;
|
|
81
90
|
let skipped = 0;
|
|
82
91
|
for (const record of records) {
|
|
83
92
|
for (const fieldName of fileFieldNames) {
|
|
84
93
|
for (const fileRefId of fileRefIdsOf(record[fieldName])) {
|
|
85
|
-
const row =
|
|
86
|
-
if (row ===
|
|
94
|
+
const row = fileRefRows.get(fileRefId);
|
|
95
|
+
if (row === undefined ||
|
|
87
96
|
!isBindableToRecord(row, {
|
|
88
97
|
record,
|
|
89
98
|
entityType,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
-
import type { Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
3
|
-
import { type CreatedFileRef } from "./create-file-ref.js";
|
|
2
|
+
import type { BeforeTransactionResult, Registry, SessionUser, WriteResult } from "../engine/types/index.js";
|
|
3
|
+
import { type CreatedFileRef, type UploadedFileBytes } from "./create-file-ref.js";
|
|
4
4
|
import type { FileContext } from "./file-handle.js";
|
|
5
|
-
export type { CreatedFileRef };
|
|
5
|
+
export type { CreatedFileRef, UploadedFileBytes };
|
|
6
6
|
export type CreateFileRefContext = {
|
|
7
7
|
readonly db: TenantDb;
|
|
8
8
|
readonly files?: FileContext | undefined;
|
|
@@ -20,3 +20,6 @@ export type CreateFileRefInput = {
|
|
|
20
20
|
};
|
|
21
21
|
};
|
|
22
22
|
export declare function createFileRefFromBytes(ctx: CreateFileRefContext, input: CreateFileRefInput): Promise<WriteResult<CreatedFileRef>>;
|
|
23
|
+
export type UploadFileBytesContext = Pick<CreateFileRefContext, "db" | "files" | "registry" | "user">;
|
|
24
|
+
export declare function uploadFileBytesBeforeTransaction(ctx: UploadFileBytesContext, input: CreateFileRefInput): Promise<BeforeTransactionResult<UploadedFileBytes>>;
|
|
25
|
+
export declare function createFileRefFromUploadedBytes(ctx: Pick<CreateFileRefContext, "db" | "user">, uploaded: UploadedFileBytes): Promise<WriteResult<CreatedFileRef>>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { fetchOne } from "../bun-db/query.js";
|
|
1
2
|
import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
2
|
-
import { DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, } from "./create-file-ref.js";
|
|
3
|
+
import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcceptedFileBytes, } from "./create-file-ref.js";
|
|
4
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
3
5
|
import { readFilesRouteOptions } from "./file-routes.js";
|
|
6
|
+
import { assertSafeStorageKey } from "./types.js";
|
|
4
7
|
// Server-side counterpart of POST /files for handlers/jobs that produce bytes
|
|
5
8
|
// themselves (generated PDFs, exports). Same validation, storage layout and
|
|
6
9
|
// fileRef.created event as an upload; the tenant is always ctx.user.tenantId.
|
|
@@ -30,10 +33,70 @@ export async function createFileRefFromBytes(ctx, input) {
|
|
|
30
33
|
case "created":
|
|
31
34
|
return { isSuccess: true, data: outcome.fileRef };
|
|
32
35
|
case "rejected":
|
|
33
|
-
return
|
|
34
|
-
details: { error: outcome.error },
|
|
35
|
-
}));
|
|
36
|
+
return fileRejectedFailure(outcome.error);
|
|
36
37
|
case "failed":
|
|
37
38
|
return outcome.failure;
|
|
38
39
|
}
|
|
39
40
|
}
|
|
41
|
+
function fileRejectedFailure(error) {
|
|
42
|
+
return writeFailure(new UnprocessableError(FrameworkReasons.fileRejected, { details: { error } }));
|
|
43
|
+
}
|
|
44
|
+
// Pre-transaction half of createFileRefFromBytes: validates and writes the binary while no
|
|
45
|
+
// transaction holds a connection. Pair it with createFileRefFromUploadedBytes inside the handler.
|
|
46
|
+
// A crash between this phase and the commit leaves the bytes in storage; nothing sweeps them.
|
|
47
|
+
export async function uploadFileBytesBeforeTransaction(ctx, input) {
|
|
48
|
+
const { files } = ctx;
|
|
49
|
+
if (!files) {
|
|
50
|
+
return writeFailure(new InternalError({
|
|
51
|
+
message: "uploadFileBytesBeforeTransaction: ctx.files is unavailable — mount a file-provider feature to store files",
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
const outcome = await writeAcceptedFileBytes({
|
|
55
|
+
user: ctx.user,
|
|
56
|
+
registry: ctx.registry,
|
|
57
|
+
defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
58
|
+
writeBytes: (key, data, mimeType) => files.ref(key).write(data, mimeType),
|
|
59
|
+
}, {
|
|
60
|
+
fileName: input.fileName,
|
|
61
|
+
declaredMimeType: input.mimeType,
|
|
62
|
+
data: input.data,
|
|
63
|
+
entityType: input.attachTo?.entityType,
|
|
64
|
+
entityId: input.attachTo?.entityId,
|
|
65
|
+
fieldName: input.attachTo?.fieldName,
|
|
66
|
+
});
|
|
67
|
+
if (outcome.kind === "rejected")
|
|
68
|
+
return fileRejectedFailure(outcome.error);
|
|
69
|
+
const { uploaded } = outcome;
|
|
70
|
+
return {
|
|
71
|
+
isSuccess: true,
|
|
72
|
+
data: uploaded,
|
|
73
|
+
// Also runs after a failed COMMIT whose outcome is unknown: a committed row must keep its binary.
|
|
74
|
+
release: async () => {
|
|
75
|
+
const committedRow = await fetchOne(ctx.db, fileRefsTable, {
|
|
76
|
+
storageKey: uploaded.storageKey,
|
|
77
|
+
});
|
|
78
|
+
if (!committedRow)
|
|
79
|
+
await files.ref(uploaded.storageKey).delete();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Transaction half: creates the fileRef row for bytes uploadFileBytesBeforeTransaction wrote.
|
|
84
|
+
export async function createFileRefFromUploadedBytes(ctx, uploaded) {
|
|
85
|
+
const ownPrefix = `${ctx.user.tenantId}/`;
|
|
86
|
+
if (!uploaded.storageKey.startsWith(ownPrefix)) {
|
|
87
|
+
return writeFailure(new InternalError({
|
|
88
|
+
message: "createFileRefFromUploadedBytes: storageKey is outside the caller's tenant prefix",
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
assertSafeStorageKey(uploaded.storageKey);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return writeFailure(new InternalError({
|
|
96
|
+
message: "createFileRefFromUploadedBytes: unsafe storageKey",
|
|
97
|
+
...(error instanceof Error && { cause: error }),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
const row = await createFileRefRow({ user: ctx.user, tenantDb: ctx.db }, uploaded);
|
|
101
|
+
return "failure" in row ? row.failure : { isSuccess: true, data: row.fileRef };
|
|
102
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type TenantDb } from "../db/tenant-db.js";
|
|
2
2
|
import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.js";
|
|
3
3
|
import type { WriteFailure } from "../errors/index.js";
|
|
4
4
|
export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
@@ -24,6 +24,7 @@ export type StoreFileInput = {
|
|
|
24
24
|
export type StoreFileDeps = {
|
|
25
25
|
readonly user: SessionUser;
|
|
26
26
|
readonly tenantDb: TenantDb;
|
|
27
|
+
readonly rowInOwnTransaction?: boolean;
|
|
27
28
|
readonly registry: Registry | undefined;
|
|
28
29
|
readonly defaultMaxUploadSize: string;
|
|
29
30
|
readonly writeBytes: (storageKey: string, data: Uint8Array, mimeType: string) => Promise<void>;
|
|
@@ -36,6 +37,22 @@ export type CreatedFileRef = {
|
|
|
36
37
|
readonly size: number;
|
|
37
38
|
readonly storageKey: string;
|
|
38
39
|
};
|
|
40
|
+
export type UploadedFileBytes = {
|
|
41
|
+
readonly storageKey: string;
|
|
42
|
+
readonly fileName: string;
|
|
43
|
+
readonly mimeType: string;
|
|
44
|
+
readonly size: number;
|
|
45
|
+
readonly entityType?: string | undefined;
|
|
46
|
+
readonly entityId?: string | undefined;
|
|
47
|
+
readonly fieldName?: string | undefined;
|
|
48
|
+
};
|
|
49
|
+
export type UploadBytesOutcome = {
|
|
50
|
+
readonly kind: "uploaded";
|
|
51
|
+
readonly uploaded: UploadedFileBytes;
|
|
52
|
+
} | {
|
|
53
|
+
readonly kind: "rejected";
|
|
54
|
+
readonly error: string;
|
|
55
|
+
};
|
|
39
56
|
export type StoreFileOutcome = {
|
|
40
57
|
readonly kind: "created";
|
|
41
58
|
readonly fileRef: CreatedFileRef;
|
|
@@ -46,5 +63,11 @@ export type StoreFileOutcome = {
|
|
|
46
63
|
readonly kind: "failed";
|
|
47
64
|
readonly failure: WriteFailure;
|
|
48
65
|
};
|
|
66
|
+
export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
|
|
67
|
+
export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
|
|
68
|
+
readonly fileRef: CreatedFileRef;
|
|
69
|
+
} | {
|
|
70
|
+
readonly failure: WriteFailure;
|
|
71
|
+
}>;
|
|
49
72
|
export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
|
|
50
73
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
2
|
+
import { runInOwnTransaction } from "../db/tenant-db.js";
|
|
2
3
|
import { isFileField, } from "../engine/types/index.js";
|
|
3
4
|
import { generateId } from "../utils/index.js";
|
|
4
5
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
@@ -92,44 +93,85 @@ function acceptFile(deps, input) {
|
|
|
92
93
|
extension: undefined,
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
96
|
+
// Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
|
|
97
|
+
// I/O doesn't belong inside a PG connection's tx window.
|
|
98
|
+
export async function writeAcceptedFileBytes(deps, input) {
|
|
97
99
|
const { fileName, data, entityType, entityId, fieldName } = input;
|
|
98
100
|
const accepted = acceptFile(deps, input);
|
|
99
101
|
if (accepted.kind === "rejected")
|
|
100
102
|
return accepted;
|
|
101
103
|
const { mimeType, extension } = accepted;
|
|
102
|
-
const fileRefId = generateId();
|
|
103
104
|
const storageKey = buildStorageKey(deps.user.tenantId, entityType ?? "unattached", entityId, fieldName ?? "file", fileName, generateId(), extension);
|
|
104
|
-
// Write binary FIRST (outside the tx — network/disk I/O doesn't belong
|
|
105
|
-
// inside a PG connection's tx window). A failed create deletes the bytes
|
|
106
|
-
// again below; a later rollback of an enclosing transaction cannot, so
|
|
107
|
-
// those are orphaned for the cleanup-jobs to sweep. Losing a row on
|
|
108
|
-
// append-failure is acceptable; a committed row with a missing binary is not.
|
|
109
105
|
await deps.writeBytes(storageKey, data, mimeType);
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
return {
|
|
107
|
+
kind: "uploaded",
|
|
108
|
+
uploaded: {
|
|
109
|
+
storageKey,
|
|
110
|
+
fileName,
|
|
111
|
+
mimeType,
|
|
112
|
+
size: data.byteLength,
|
|
113
|
+
entityType,
|
|
114
|
+
entityId,
|
|
115
|
+
fieldName,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Row step: id is explicit so the response + storageKey stay consistent; tenantId, insertedAt and
|
|
120
|
+
// insertedById are set by applyEntityEvent from the event metadata.
|
|
121
|
+
export async function createFileRefRow(deps, uploaded) {
|
|
122
|
+
const fileRefId = generateId();
|
|
112
123
|
const result = await getFileRefExecutor().create({
|
|
113
124
|
id: fileRefId,
|
|
114
|
-
storageKey,
|
|
115
|
-
fileName,
|
|
116
|
-
mimeType,
|
|
117
|
-
size:
|
|
118
|
-
entityType: entityType ?? null,
|
|
119
|
-
entityId: entityId ?? null,
|
|
120
|
-
fieldName: fieldName ?? null,
|
|
125
|
+
storageKey: uploaded.storageKey,
|
|
126
|
+
fileName: uploaded.fileName,
|
|
127
|
+
mimeType: uploaded.mimeType,
|
|
128
|
+
size: uploaded.size,
|
|
129
|
+
entityType: uploaded.entityType ?? null,
|
|
130
|
+
entityId: uploaded.entityId ?? null,
|
|
131
|
+
fieldName: uploaded.fieldName ?? null,
|
|
121
132
|
}, deps.user, deps.tenantDb);
|
|
122
|
-
if (!result.isSuccess)
|
|
133
|
+
if (!result.isSuccess)
|
|
134
|
+
return { failure: result };
|
|
135
|
+
return {
|
|
136
|
+
fileRef: {
|
|
137
|
+
id: fileRefId,
|
|
138
|
+
fileName: uploaded.fileName,
|
|
139
|
+
mimeType: uploaded.mimeType,
|
|
140
|
+
size: uploaded.size,
|
|
141
|
+
storageKey: uploaded.storageKey,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// The one upload pipeline behind POST /files and createFileRefFromBytes.
|
|
146
|
+
export async function storeFileAsFileRef(deps, input) {
|
|
147
|
+
const upload = await writeAcceptedFileBytes(deps, input);
|
|
148
|
+
if (upload.kind === "rejected")
|
|
149
|
+
return upload;
|
|
150
|
+
// A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
|
|
151
|
+
// crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
|
|
152
|
+
// Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
|
|
153
|
+
// A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
|
|
154
|
+
const deleteUploadedBytes = async () => {
|
|
123
155
|
try {
|
|
124
|
-
await deps.deleteBytes(storageKey);
|
|
156
|
+
await deps.deleteBytes(upload.uploaded.storageKey);
|
|
125
157
|
}
|
|
126
158
|
catch {
|
|
127
|
-
// skip: the create failure is the error worth reporting; leftover bytes
|
|
159
|
+
// skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
|
|
128
160
|
}
|
|
129
|
-
return { kind: "failed", failure: result };
|
|
130
|
-
}
|
|
131
|
-
return {
|
|
132
|
-
kind: "created",
|
|
133
|
-
fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
|
|
134
161
|
};
|
|
162
|
+
let row;
|
|
163
|
+
try {
|
|
164
|
+
row = deps.rowInOwnTransaction
|
|
165
|
+
? await runInOwnTransaction(deps.tenantDb, (txDb) => createFileRefRow({ user: deps.user, tenantDb: txDb }, upload.uploaded))
|
|
166
|
+
: await createFileRefRow(deps, upload.uploaded);
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
await deleteUploadedBytes();
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
if ("failure" in row) {
|
|
173
|
+
await deleteUploadedBytes();
|
|
174
|
+
return { kind: "failed", failure: row.failure };
|
|
175
|
+
}
|
|
176
|
+
return { kind: "created", fileRef: row.fileRef };
|
|
135
177
|
}
|
|
@@ -3,16 +3,31 @@ import { reraiseAsKumikoError } from "../errors/index.js";
|
|
|
3
3
|
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
4
4
|
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
5
5
|
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
-
|
|
6
|
+
// The bound fieldName may differ from the field the id was removed from: an id
|
|
7
|
+
// moved A -> B stays bound to A (binding never re-targets).
|
|
8
|
+
function isBoundToRecord(row, entityName, entityId, fileFields) {
|
|
9
|
+
const boundField = row["fieldName"];
|
|
7
10
|
return (row["entityType"] === entityName &&
|
|
8
11
|
row["entityId"] === entityId &&
|
|
9
|
-
|
|
12
|
+
typeof boundField === "string" &&
|
|
13
|
+
isFileField(fileFields[boundField]));
|
|
14
|
+
}
|
|
15
|
+
// Across all file fields: an id moved from one field to another is still in use.
|
|
16
|
+
function fileRefIdsStoredInAnyField(fields, data) {
|
|
17
|
+
const ids = new Set();
|
|
18
|
+
for (const [fieldName, fieldDef] of Object.entries(fields)) {
|
|
19
|
+
if (!isFileField(fieldDef))
|
|
20
|
+
continue;
|
|
21
|
+
for (const fileRefId of fileRefIdsOf(data[fieldName]))
|
|
22
|
+
ids.add(fileRefId);
|
|
23
|
+
}
|
|
24
|
+
return ids;
|
|
10
25
|
}
|
|
11
26
|
// An update that drops or replaces a file id leaves the fileRef readable to
|
|
12
27
|
// everyone who may read the record. Deleting it goes through the same executor
|
|
13
28
|
// path as DELETE /files/:id (soft delete, bytes kept until retention), so
|
|
14
29
|
// record readers get 404 afterwards. Only fileRefs bound to exactly this
|
|
15
|
-
// record
|
|
30
|
+
// record are touched: an id that is pending, shared or bound
|
|
16
31
|
// elsewhere is not this save's to dispose. The authority is the caller's write
|
|
17
32
|
// right on the record (the fileRef entity declares no write access of its own).
|
|
18
33
|
export async function disposeRemovedFileRefs(args) {
|
|
@@ -33,15 +48,15 @@ export async function disposeRemovedFileRefs(args) {
|
|
|
33
48
|
return;
|
|
34
49
|
const entityId = String(result.id);
|
|
35
50
|
const executor = getFileRefExecutor();
|
|
51
|
+
const keptAnywhere = fileRefIdsStoredInAnyField(entity.fields, result.data);
|
|
36
52
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
37
53
|
if (!isFileField(fieldDef) || !(fieldName in result.changes))
|
|
38
54
|
continue;
|
|
39
|
-
const kept = new Set(fileRefIdsOf(result.data[fieldName]));
|
|
40
55
|
for (const fileRefId of fileRefIdsOf(result.previous[fieldName])) {
|
|
41
|
-
if (
|
|
56
|
+
if (keptAnywhere.has(fileRefId))
|
|
42
57
|
continue;
|
|
43
58
|
const row = await executor.detail({ id: fileRefId }, user, db);
|
|
44
|
-
if (row === null || !
|
|
59
|
+
if (row === null || !isBoundToRecord(row, entityName, entityId, entity.fields))
|
|
45
60
|
continue;
|
|
46
61
|
const removed = await executor.delete({ id: fileRefId }, user, db);
|
|
47
62
|
if (!removed.isSuccess)
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -7,4 +7,5 @@ export type FilesFeatureOptions = {
|
|
|
7
7
|
readonly unclaimedUploadReaders?: readonly string[];
|
|
8
8
|
readonly maxUploadSize?: string;
|
|
9
9
|
};
|
|
10
|
+
export declare const FILES_FEATURE: "files";
|
|
10
11
|
export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
|
package/dist/files/feature.js
CHANGED
|
@@ -13,8 +13,9 @@ export { fileRefEntity } from "./file-ref-entity.js";
|
|
|
13
13
|
// userData/tenantData-Extensions sichtbar und registriert die implizite
|
|
14
14
|
// Entity-Projektion (für rebuildProjection). Liegt im Framework neben
|
|
15
15
|
// file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
|
|
16
|
+
export const FILES_FEATURE = "files";
|
|
16
17
|
export function createFilesFeature(options = {}) {
|
|
17
|
-
return defineFeature(
|
|
18
|
+
return defineFeature(FILES_FEATURE, (r) => {
|
|
18
19
|
r.describe("Exposes the `fileRef` entity from the framework core so that uploaded files — tracked in the `file_refs` table by `createFileRoutes` — participate in cross-feature hooks: `user-data-rights-defaults` automatically includes file blobs in GDPR exports and forget flows, and tenant-lifecycle cleanup deletes all refs on tenant destroy. Upload/download routes are registered by the server bootstrap when a `file-provider-*` feature is mounted; this feature carries their access/size policy via `createFilesFeature(opts?)`.");
|
|
19
20
|
r.uiHints({
|
|
20
21
|
displayLabel: "Files · Metadata",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
2
|
-
import type
|
|
2
|
+
import { type FileStorageProvider } from "./types.js";
|
|
3
3
|
export type { FileContext, FileHandle };
|
|
4
4
|
export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
|
|
5
5
|
export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// `foo/bar.medium-<16 hex>.jpg`. The suffix must be a `variantSuffix(...)` so
|
|
12
12
|
// the erasure sweep recognizes it. Stable, reversible, no extra lookup tables.
|
|
13
13
|
import { isDerivativeSuffix } from "../derivatives/variant-key.js";
|
|
14
|
+
import { assertSafeStorageKey } from "./types.js";
|
|
14
15
|
// `getProvider` is a lazily-resolved, memoized accessor — the provider is
|
|
15
16
|
// resolved (config + s3.secretAccessKey secret read) only when a handle method
|
|
16
17
|
// actually does I/O, never on every request that merely builds a ctx.
|
|
@@ -54,6 +55,17 @@ export function createFileContext(resolve, tenantId) {
|
|
|
54
55
|
}
|
|
55
56
|
return createFileHandle(key, getProvider);
|
|
56
57
|
},
|
|
58
|
+
/** Validates only the storage key shape (assertSafeStorageKey), no tenant prefix: keys
|
|
59
|
+
* may outlive a tenant handover. The caller must ensure the fileRef row came from a
|
|
60
|
+
* tenant-filtered read. */
|
|
61
|
+
refFromFileRef: (row) => {
|
|
62
|
+
assertSafeStorageKey(row.storageKey);
|
|
63
|
+
return createFileHandle(row.storageKey, getProvider);
|
|
64
|
+
},
|
|
65
|
+
listFromFileRef: async (row) => {
|
|
66
|
+
assertSafeStorageKey(row.storageKey);
|
|
67
|
+
return (await getProvider()).list(storageKeyStemPrefix(row.storageKey));
|
|
68
|
+
},
|
|
57
69
|
list: async (prefix) => {
|
|
58
70
|
if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
|
|
59
71
|
throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
|
|
@@ -4,8 +4,9 @@ import { getUser } from "../api/auth-middleware.js";
|
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
5
|
import { selectMany } from "../bun-db/index.js";
|
|
6
6
|
import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { createProjectingTenantDb } from "../db/tenant-db.js";
|
|
8
8
|
import { createDerivativesContext, resolveFieldVariant, resolveRenderer, } from "../derivatives/index.js";
|
|
9
|
+
import { SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
9
10
|
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
10
11
|
import { buildContentDispositionHeader } from "./content-disposition.js";
|
|
11
12
|
import { DEFAULT_MAX_UPLOAD_SIZE, getFileRefExecutor, storeFileAsFileRef, } from "./create-file-ref.js";
|
|
@@ -32,8 +33,11 @@ function createDefaultGuard(privilegedRoles, canReadRecord, unclaimedUploadReade
|
|
|
32
33
|
return "allow";
|
|
33
34
|
if (privilegedRoles.some((role) => user.roles.includes(role)))
|
|
34
35
|
return "allow";
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
// System-generated documents are created without attachTo too, but belong to a person's data, not to an inbox.
|
|
37
|
+
const isUnclaimedUpload = fileRef.entityType === null &&
|
|
38
|
+
fileRef.entityId === null &&
|
|
39
|
+
fileRef.insertedById !== SYSTEM_USER_ID;
|
|
40
|
+
if (isUnclaimedUpload &&
|
|
37
41
|
operation === "read" &&
|
|
38
42
|
unclaimedUploadReaders.some((role) => user.roles.includes(role))) {
|
|
39
43
|
return "allow";
|
|
@@ -88,8 +92,7 @@ export function createFileRoutes(options) {
|
|
|
88
92
|
const executor = getFileRefExecutor();
|
|
89
93
|
const piiSubjectFields = collectPiiSubjectFields(fileRefEntity);
|
|
90
94
|
const api = new Hono();
|
|
91
|
-
const
|
|
92
|
-
const tenantDbFor = (tenantId) => createTenantDb(db, tenantId, "tenant", undefined, undefined, undefined, projectionGrants);
|
|
95
|
+
const tenantDbFor = (tenantId) => createProjectingTenantDb(db, tenantId, options.registry);
|
|
93
96
|
const fileNameIsPersonal = piiSubjectFields.includes("fileName");
|
|
94
97
|
function kmsContextFor() {
|
|
95
98
|
return { requestId: requestContext.get()?.requestId ?? "file-routes" };
|
|
@@ -171,6 +174,7 @@ export function createFileRoutes(options) {
|
|
|
171
174
|
const outcome = await storeFileAsFileRef({
|
|
172
175
|
user,
|
|
173
176
|
tenantDb: tenantDbFor(user.tenantId),
|
|
177
|
+
rowInOwnTransaction: true,
|
|
174
178
|
registry: options.registry,
|
|
175
179
|
defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
176
180
|
writeBytes: (key, bytes, mimeType) => storageProvider.write(key, bytes, mimeType),
|
|
@@ -261,7 +265,7 @@ export function createFileRoutes(options) {
|
|
|
261
265
|
tenantId: user.tenantId,
|
|
262
266
|
});
|
|
263
267
|
const result = await derivatives.variant(id, spec, name);
|
|
264
|
-
const data = await files.
|
|
268
|
+
const data = await files.refFromFileRef(result).read();
|
|
265
269
|
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
266
270
|
// size, and a variant is rendered for display, not for download.
|
|
267
271
|
// storageKey embeds specHash(spec), so the URL is content-stable until
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, } from "./create-file-ref-from-bytes.js";
|
|
2
|
-
export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
|
|
3
|
-
export { createFilesFeature } from "./feature.js";
|
|
1
|
+
export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.js";
|
|
2
|
+
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
3
|
+
export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
4
4
|
export type { FileContext, FileHandle } from "./file-handle.js";
|
|
5
5
|
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
|
|
6
6
|
export { fileRefEntity } from "./file-ref-entity.js";
|
package/dist/files/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
|
|
2
|
-
export { createFilesFeature } from "./feature.js";
|
|
1
|
+
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
2
|
+
export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
3
3
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
4
4
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
5
5
|
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TenantDb } from "../db/tenant-db.js";
|
|
2
|
+
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.js";
|
|
3
|
+
export type FileRefRows = ReadonlyMap<string, Readonly<Record<string, unknown>>>;
|
|
4
|
+
export declare function loadSaveFileRefs(args: {
|
|
5
|
+
readonly registry: Registry;
|
|
6
|
+
readonly result: SaveContext;
|
|
7
|
+
readonly user: SessionUser;
|
|
8
|
+
readonly db: TenantDb;
|
|
9
|
+
}): Promise<FileRefRows>;
|
|
10
|
+
export declare function loadFileRefRows(db: TenantDb, ids: readonly string[], tenantId: SessionUser["tenantId"]): Promise<FileRefRows>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { selectMany } from "../db/query.js";
|
|
2
|
+
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
3
|
+
import { isFileField, } from "../engine/types/index.js";
|
|
4
|
+
import { getStreamVersions } from "../event-store/event-store.js";
|
|
5
|
+
import { fileRefIdsOf } from "./bind-pending-file-refs.js";
|
|
6
|
+
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
7
|
+
import { fileRefsTable } from "./file-ref-table.js";
|
|
8
|
+
// One query for every fileRef a save names, restricted to the writer's own tenant and live
|
|
9
|
+
// rows: the tenant check and the pending-upload binding both work from this map instead of
|
|
10
|
+
// reading each row again inside the write transaction.
|
|
11
|
+
export async function loadSaveFileRefs(args) {
|
|
12
|
+
const { registry, result, user, db } = args;
|
|
13
|
+
const entity = result.entityName === undefined ? undefined : registry.getEntity(result.entityName);
|
|
14
|
+
// skip: saves of unregistered or unnamed aggregates declare no file fields
|
|
15
|
+
if (entity === undefined)
|
|
16
|
+
return new Map();
|
|
17
|
+
// skip: without the files feature the file_refs table is not part of the schema
|
|
18
|
+
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
19
|
+
return new Map();
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
22
|
+
if (!isFileField(fieldDef))
|
|
23
|
+
continue;
|
|
24
|
+
for (const id of fileRefIdsOf(result.changes[fieldName]))
|
|
25
|
+
ids.add(id);
|
|
26
|
+
}
|
|
27
|
+
return loadFileRefRows(db, [...ids], user.tenantId);
|
|
28
|
+
}
|
|
29
|
+
// One query for the given live fileRefs of one tenant. Each row carries the stream version as
|
|
30
|
+
// `version`: the binding update needs it as expectedVersion, and events without a projection
|
|
31
|
+
// effect (e.g. ingest requests) advance the stream but not the row's version column.
|
|
32
|
+
export async function loadFileRefRows(db, ids, tenantId) {
|
|
33
|
+
// skip: nothing referenced, nothing to load
|
|
34
|
+
if (ids.length === 0)
|
|
35
|
+
return new Map();
|
|
36
|
+
const rows = await selectMany(db, fileRefsTable, {
|
|
37
|
+
id: { in: [...ids] },
|
|
38
|
+
tenantId,
|
|
39
|
+
isDeleted: false,
|
|
40
|
+
});
|
|
41
|
+
const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
|
|
42
|
+
return new Map(rows.map((row) => {
|
|
43
|
+
const id = String(row["id"]);
|
|
44
|
+
const streamVersion = streamVersions.get(id);
|
|
45
|
+
return [id, streamVersion === undefined ? row : { ...row, version: streamVersion }];
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
@@ -4,11 +4,14 @@ import { deleteMany, type WhereObject } from "../db/query.js";
|
|
|
4
4
|
import type { TenantDb } from "../db/tenant-db.js";
|
|
5
5
|
export declare const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
6
6
|
type PurgeLog = Pick<Logger, "warn"> | undefined;
|
|
7
|
+
type PurgeTable = Parameters<typeof deleteMany>[1];
|
|
7
8
|
export declare function purgeExpiredFileRefsWithBytes(args: {
|
|
8
9
|
readonly db: TenantDb;
|
|
9
|
-
readonly table:
|
|
10
|
+
readonly table: PurgeTable;
|
|
10
11
|
readonly where: WhereObject;
|
|
12
|
+
readonly expiredBefore: Temporal.Instant;
|
|
11
13
|
readonly files: FileContext | undefined;
|
|
12
14
|
readonly log: PurgeLog;
|
|
15
|
+
readonly pageSize?: number;
|
|
13
16
|
}): Promise<void>;
|
|
14
17
|
export {};
|