@cosmicdrift/kumiko-framework 0.362.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- 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 +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- 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 +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -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 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -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
|
+
}
|
|
@@ -36,6 +36,22 @@ export type CreatedFileRef = {
|
|
|
36
36
|
readonly size: number;
|
|
37
37
|
readonly storageKey: string;
|
|
38
38
|
};
|
|
39
|
+
export type UploadedFileBytes = {
|
|
40
|
+
readonly storageKey: string;
|
|
41
|
+
readonly fileName: string;
|
|
42
|
+
readonly mimeType: string;
|
|
43
|
+
readonly size: number;
|
|
44
|
+
readonly entityType?: string | undefined;
|
|
45
|
+
readonly entityId?: string | undefined;
|
|
46
|
+
readonly fieldName?: string | undefined;
|
|
47
|
+
};
|
|
48
|
+
export type UploadBytesOutcome = {
|
|
49
|
+
readonly kind: "uploaded";
|
|
50
|
+
readonly uploaded: UploadedFileBytes;
|
|
51
|
+
} | {
|
|
52
|
+
readonly kind: "rejected";
|
|
53
|
+
readonly error: string;
|
|
54
|
+
};
|
|
39
55
|
export type StoreFileOutcome = {
|
|
40
56
|
readonly kind: "created";
|
|
41
57
|
readonly fileRef: CreatedFileRef;
|
|
@@ -46,5 +62,11 @@ export type StoreFileOutcome = {
|
|
|
46
62
|
readonly kind: "failed";
|
|
47
63
|
readonly failure: WriteFailure;
|
|
48
64
|
};
|
|
65
|
+
export declare function writeAcceptedFileBytes(deps: Pick<StoreFileDeps, "user" | "registry" | "defaultMaxUploadSize" | "writeBytes">, input: StoreFileInput): Promise<UploadBytesOutcome>;
|
|
66
|
+
export declare function createFileRefRow(deps: Pick<StoreFileDeps, "user" | "tenantDb">, uploaded: UploadedFileBytes): Promise<{
|
|
67
|
+
readonly fileRef: CreatedFileRef;
|
|
68
|
+
} | {
|
|
69
|
+
readonly failure: WriteFailure;
|
|
70
|
+
}>;
|
|
49
71
|
export declare function storeFileAsFileRef(deps: StoreFileDeps, input: StoreFileInput): Promise<StoreFileOutcome>;
|
|
50
72
|
export {};
|
|
@@ -92,44 +92,72 @@ function acceptFile(deps, input) {
|
|
|
92
92
|
extension: undefined,
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
95
|
+
// Upload step: validates and writes the binary. Outside any transaction on purpose — network/disk
|
|
96
|
+
// I/O doesn't belong inside a PG connection's tx window.
|
|
97
|
+
export async function writeAcceptedFileBytes(deps, input) {
|
|
97
98
|
const { fileName, data, entityType, entityId, fieldName } = input;
|
|
98
99
|
const accepted = acceptFile(deps, input);
|
|
99
100
|
if (accepted.kind === "rejected")
|
|
100
101
|
return accepted;
|
|
101
102
|
const { mimeType, extension } = accepted;
|
|
102
|
-
const fileRefId = generateId();
|
|
103
103
|
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
104
|
await deps.writeBytes(storageKey, data, mimeType);
|
|
110
|
-
|
|
111
|
-
|
|
105
|
+
return {
|
|
106
|
+
kind: "uploaded",
|
|
107
|
+
uploaded: {
|
|
108
|
+
storageKey,
|
|
109
|
+
fileName,
|
|
110
|
+
mimeType,
|
|
111
|
+
size: data.byteLength,
|
|
112
|
+
entityType,
|
|
113
|
+
entityId,
|
|
114
|
+
fieldName,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Row step: id is explicit so the response + storageKey stay consistent; tenantId, insertedAt and
|
|
119
|
+
// insertedById are set by applyEntityEvent from the event metadata.
|
|
120
|
+
export async function createFileRefRow(deps, uploaded) {
|
|
121
|
+
const fileRefId = generateId();
|
|
112
122
|
const result = await getFileRefExecutor().create({
|
|
113
123
|
id: fileRefId,
|
|
114
|
-
storageKey,
|
|
115
|
-
fileName,
|
|
116
|
-
mimeType,
|
|
117
|
-
size:
|
|
118
|
-
entityType: entityType ?? null,
|
|
119
|
-
entityId: entityId ?? null,
|
|
120
|
-
fieldName: fieldName ?? null,
|
|
124
|
+
storageKey: uploaded.storageKey,
|
|
125
|
+
fileName: uploaded.fileName,
|
|
126
|
+
mimeType: uploaded.mimeType,
|
|
127
|
+
size: uploaded.size,
|
|
128
|
+
entityType: uploaded.entityType ?? null,
|
|
129
|
+
entityId: uploaded.entityId ?? null,
|
|
130
|
+
fieldName: uploaded.fieldName ?? null,
|
|
121
131
|
}, deps.user, deps.tenantDb);
|
|
122
|
-
if (!result.isSuccess)
|
|
132
|
+
if (!result.isSuccess)
|
|
133
|
+
return { failure: result };
|
|
134
|
+
return {
|
|
135
|
+
fileRef: {
|
|
136
|
+
id: fileRefId,
|
|
137
|
+
fileName: uploaded.fileName,
|
|
138
|
+
mimeType: uploaded.mimeType,
|
|
139
|
+
size: uploaded.size,
|
|
140
|
+
storageKey: uploaded.storageKey,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// The one upload pipeline behind POST /files and createFileRefFromBytes.
|
|
145
|
+
export async function storeFileAsFileRef(deps, input) {
|
|
146
|
+
const upload = await writeAcceptedFileBytes(deps, input);
|
|
147
|
+
if (upload.kind === "rejected")
|
|
148
|
+
return upload;
|
|
149
|
+
// A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
|
|
150
|
+
// crash cannot, so those bytes stay in storage (no job sweeps an original without a fileRef row).
|
|
151
|
+
// Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
|
|
152
|
+
const row = await createFileRefRow(deps, upload.uploaded);
|
|
153
|
+
if ("failure" in row) {
|
|
123
154
|
try {
|
|
124
|
-
await deps.deleteBytes(storageKey);
|
|
155
|
+
await deps.deleteBytes(upload.uploaded.storageKey);
|
|
125
156
|
}
|
|
126
157
|
catch {
|
|
127
|
-
// skip: the create failure is the error worth reporting; leftover bytes
|
|
158
|
+
// skip: the create failure is the error worth reporting; the leftover bytes stay in storage.
|
|
128
159
|
}
|
|
129
|
-
return { kind: "failed", failure:
|
|
160
|
+
return { kind: "failed", failure: row.failure };
|
|
130
161
|
}
|
|
131
|
-
return {
|
|
132
|
-
kind: "created",
|
|
133
|
-
fileRef: { id: fileRefId, fileName, mimeType, size: data.byteLength, storageKey },
|
|
134
|
-
};
|
|
162
|
+
return { kind: "created", fileRef: row.fileRef };
|
|
135
163
|
}
|
|
@@ -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,13 @@ 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
|
+
},
|
|
57
65
|
list: async (prefix) => {
|
|
58
66
|
if (tenantId !== undefined && !prefix.startsWith(`${tenantId}/`)) {
|
|
59
67
|
throw new Error(`FileContext.list: prefix must start with "${tenantId}/"`);
|
|
@@ -74,7 +74,7 @@ export function readFilesRouteOptions(registry) {
|
|
|
74
74
|
if (exp && typeof exp === "object" && "routeOptions" in exp) {
|
|
75
75
|
const ro = exp.routeOptions;
|
|
76
76
|
if (ro && typeof ro === "object") {
|
|
77
|
-
// @cast-boundary feature-exports
|
|
77
|
+
// @cast-boundary feature-exports — typeof-checked read-back from untyped feature.exports
|
|
78
78
|
return ro;
|
|
79
79
|
}
|
|
80
80
|
}
|
|
@@ -261,7 +261,7 @@ export function createFileRoutes(options) {
|
|
|
261
261
|
tenantId: user.tenantId,
|
|
262
262
|
});
|
|
263
263
|
const result = await derivatives.variant(id, spec, name);
|
|
264
|
-
const data = await files.
|
|
264
|
+
const data = await files.refFromFileRef(result).read();
|
|
265
265
|
// No Content-Length/Content-Disposition: fileRef.size is the ORIGINAL's
|
|
266
266
|
// size, and a variant is rendered for display, not for download.
|
|
267
267
|
// storageKey embeds specHash(spec), so the URL is content-stable until
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, } from "./create-file-ref-from-bytes.js";
|
|
2
|
-
export { createFileRefFromBytes } from "./create-file-ref-from-bytes.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
3
|
export { createFilesFeature } from "./feature.js";
|
|
4
4
|
export type { FileContext, FileHandle } from "./file-handle.js";
|
|
5
5
|
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
|
package/dist/files/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createFileRefFromBytes } from "./create-file-ref-from-bytes.js";
|
|
1
|
+
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
2
2
|
export { createFilesFeature } from "./feature.js";
|
|
3
3
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
4
4
|
// `createFileContext(provider).ref(key)`, which is the AppContext surface.
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createReadStream, createWriteStream } from "node:fs";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
6
|
import { assertSafeStorageKey } from "./types.js";
|
|
6
7
|
// Local-filesystem backend — intended for dev + tests. Production deploys
|
|
7
8
|
// pick an object-store provider (S3/R2/…). mimeType is ignored here; the
|
|
@@ -112,7 +113,7 @@ export function createLocalProvider(basePath) {
|
|
|
112
113
|
entries = await readdir(walkRoot, { recursive: true, withFileTypes: true });
|
|
113
114
|
}
|
|
114
115
|
catch (err) {
|
|
115
|
-
const code = err
|
|
116
|
+
const code = errorCodeOf(err);
|
|
116
117
|
if (code === "ENOENT" || code === "ENOTDIR")
|
|
117
118
|
return [];
|
|
118
119
|
throw err;
|
|
@@ -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 {};
|
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
import { deleteMany, selectMany } from "../db/query.js";
|
|
2
2
|
import { deleteStoredFileAndDerivatives } from "../derivatives/delete-stored-file.js";
|
|
3
3
|
export const EXPIRED_FILE_REF_PURGE_BATCH = 500;
|
|
4
|
+
const PAGE_ORDER = [
|
|
5
|
+
{ col: "deletedAt", direction: "asc" },
|
|
6
|
+
{ col: "id", direction: "asc" },
|
|
7
|
+
];
|
|
8
|
+
// Keyset page over (deletedAt, id): rows at the cursor's instant with a larger id first, then
|
|
9
|
+
// strictly later instants. `where` already bounds deletedAt by the cutoff; the later-instants
|
|
10
|
+
// query repeats that bound next to the cursor.
|
|
11
|
+
async function selectNextPage(args) {
|
|
12
|
+
const { db, table, where, expiredBefore, cursor, pageSize } = args;
|
|
13
|
+
const options = { orderBy: PAGE_ORDER, limit: pageSize };
|
|
14
|
+
if (cursor === undefined)
|
|
15
|
+
return selectMany(db, table, where, options);
|
|
16
|
+
const sameInstant = await selectMany(db, table, { ...where, deletedAt: cursor.deletedAt, id: { gt: cursor.id } }, options);
|
|
17
|
+
if (sameInstant.length >= pageSize)
|
|
18
|
+
return sameInstant;
|
|
19
|
+
const later = await selectMany(db, table, { ...where, deletedAt: { gt: cursor.deletedAt, lt: expiredBefore } }, { ...options, limit: pageSize - sameInstant.length });
|
|
20
|
+
return [...sameInstant, ...later];
|
|
21
|
+
}
|
|
4
22
|
// Deletes the bytes (original + derivatives) of expired soft-deleted fileRef rows, then the
|
|
5
23
|
// rows. Bytes first and fail-closed: once the row is gone nobody knows the storageKey, so a
|
|
6
|
-
// row whose bytes could not be removed stays and the next run retries it.
|
|
24
|
+
// row whose bytes could not be removed stays and the next run retries it. A run walks the
|
|
25
|
+
// expired rows page by page along (deletedAt, id), so rows that keep failing cannot hide the
|
|
26
|
+
// rows behind them; the run ends on the first empty page.
|
|
7
27
|
export async function purgeExpiredFileRefsWithBytes(args) {
|
|
8
|
-
const { db, table, where, files, log } = args;
|
|
28
|
+
const { db, table, where, expiredBefore, files, log } = args;
|
|
29
|
+
const pageSize = args.pageSize ?? EXPIRED_FILE_REF_PURGE_BATCH;
|
|
9
30
|
if (!files) {
|
|
10
31
|
log?.warn("[soft-delete-cleanup] no file storage available, expired fileRef rows kept");
|
|
11
32
|
return;
|
|
12
33
|
}
|
|
13
|
-
const rows = await selectMany(db, table, where, {
|
|
14
|
-
orderBy: [{ col: "deletedAt", direction: "asc" }],
|
|
15
|
-
limit: EXPIRED_FILE_REF_PURGE_BATCH,
|
|
16
|
-
});
|
|
17
|
-
const purgedIds = [];
|
|
18
34
|
const store = { list: files.list, delete: (key) => files.ref(key).delete() };
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
35
|
+
let cursor;
|
|
36
|
+
for (;;) {
|
|
37
|
+
const rows = await selectNextPage({ db, table, where, expiredBefore, cursor, pageSize });
|
|
38
|
+
const lastRow = rows[rows.length - 1];
|
|
39
|
+
// skip: an empty page means every expired row has been visited
|
|
40
|
+
if (lastRow === undefined)
|
|
41
|
+
return;
|
|
42
|
+
cursor = lastRow;
|
|
43
|
+
const purgedIds = [];
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
try {
|
|
46
|
+
const failedKeys = await deleteStoredFileAndDerivatives(row.storageKey, store, "soft-delete-cleanup:fileRef");
|
|
47
|
+
if (failedKeys.length === 0) {
|
|
48
|
+
purgedIds.push(row.id);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
25
55
|
}
|
|
26
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not fully deleted, row kept`);
|
|
27
|
-
}
|
|
28
|
-
catch (err) {
|
|
29
|
-
log?.warn(`[soft-delete-cleanup] bytes of fileRef ${row.id} not deleted, row kept: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
56
|
}
|
|
57
|
+
if (purgedIds.length > 0)
|
|
58
|
+
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
31
59
|
}
|
|
32
|
-
if (purgedIds.length > 0)
|
|
33
|
-
await deleteMany(db, table, { ...where, id: { in: purgedIds } });
|
|
34
60
|
}
|
package/dist/i18n/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Registry } from "../engine/types/index.js";
|
|
2
|
-
export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
2
|
+
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
3
3
|
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
|
|
5
5
|
export type I18nOptions = {
|
package/dist/i18n/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveTranslationValue } from "../ui-types/plural.js";
|
|
2
|
-
export { hasMailTranslations, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
2
|
+
export { hasMailTranslationKey, hasMailTranslations, listMailLocales, mailT, registerMailTranslations, resolveMailLocale, } from "./mail-registry.js";
|
|
3
3
|
export { canonicalizeLocaleTag, DEFAULT_LOCALE, isValidLocaleTag, pickAcceptLanguage, resolveHeaderLocale, } from "./request-locale.js";
|
|
4
4
|
export { SETTINGS_HUB_I18N } from "./settings-hub-keys.js";
|
|
5
5
|
export function createI18n(registry, options) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type TranslationValue } from "../ui-types/plural.js";
|
|
2
2
|
export declare function registerMailTranslations(locale: string, bundle: Readonly<Record<string, TranslationValue>>): void;
|
|
3
|
+
export declare function listMailLocales(): readonly string[];
|
|
4
|
+
export declare function hasMailTranslationKey(locale: string, key: string): boolean;
|
|
3
5
|
export declare function hasMailTranslations(locale: string): boolean;
|
|
4
6
|
export declare function mailT(locale: string, key: string, params?: Readonly<Record<string, string | number>>): string;
|
|
5
7
|
/** Locale key mailT would actually use (exact → root → en). Use for appUrl
|
|
@@ -4,6 +4,13 @@ export function registerMailTranslations(locale, bundle) {
|
|
|
4
4
|
const prev = tables.get(locale) ?? {};
|
|
5
5
|
tables.set(locale, { ...prev, ...bundle });
|
|
6
6
|
}
|
|
7
|
+
export function listMailLocales() {
|
|
8
|
+
return [...tables.keys()];
|
|
9
|
+
}
|
|
10
|
+
// Own key only, without the root/en fallback mailT applies.
|
|
11
|
+
export function hasMailTranslationKey(locale, key) {
|
|
12
|
+
return tables.get(locale)?.[key] !== undefined;
|
|
13
|
+
}
|
|
7
14
|
export function hasMailTranslations(locale) {
|
|
8
15
|
const root = locale.split("-")[0] ?? locale;
|
|
9
16
|
return tables.has(locale) || tables.has(root);
|
package/dist/jobs/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export type { JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
1
|
+
export type { JobDroppedMeta, JobLogEntry, JobMeta, JobOutcomeMeta, JobQueuedMeta, JobRunner, JobRunnerOptions, JobSubjectValue, } from "./job-runner.js";
|
|
2
2
|
export { createJobRunner, serializeJobSubject } from "./job-runner.js";
|
|
@@ -29,6 +29,9 @@ export type JobQueuedMeta = {
|
|
|
29
29
|
readonly tenantId: string;
|
|
30
30
|
readonly subject: string | null;
|
|
31
31
|
};
|
|
32
|
+
export type JobDroppedMeta = {
|
|
33
|
+
readonly tenantId: string;
|
|
34
|
+
};
|
|
32
35
|
export type JobOutcomeMeta = {
|
|
33
36
|
readonly tenantId: string;
|
|
34
37
|
readonly finalAttempt: boolean;
|
|
@@ -66,6 +69,6 @@ export type JobRunnerOptions = {
|
|
|
66
69
|
onJobComplete?: (jobName: string, jobId: string, duration: number, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
67
70
|
onJobFailed?: (jobName: string, jobId: string, error: string, logs: JobLogEntry[], outcome?: JobOutcomeMeta) => void;
|
|
68
71
|
onJobQueued?: (jobName: string, jobId: string, meta: JobQueuedMeta) => void;
|
|
69
|
-
onJobDropped?: (jobName: string, jobId: string) => void;
|
|
72
|
+
onJobDropped?: (jobName: string, jobId: string, meta: JobDroppedMeta) => void;
|
|
70
73
|
};
|
|
71
74
|
export declare function createJobRunner(options: JobRunnerOptions): JobRunner;
|