@cosmicdrift/kumiko-framework 0.371.1 → 0.372.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/embedded-entity-form.md +1 -1
- package/agents/examples/pdf-audit-export.md +1 -1
- package/agents/examples/user-data-rights.md +176 -0
- package/dist/api/jwt.js +2 -1
- package/dist/api/observability-middleware.d.ts +1 -2
- package/dist/api/observability-middleware.js +9 -8
- package/dist/api/pii-leak-guard.js +2 -1
- package/dist/api/route-label.d.ts +3 -0
- package/dist/api/route-label.js +17 -0
- package/dist/api/server.d.ts +1 -2
- package/dist/api/server.js +3 -5
- package/dist/config/index.d.ts +5 -0
- package/dist/db/event-store-executor.d.ts +2 -1
- package/dist/db/index.d.ts +2 -2
- package/dist/db/index.js +1 -1
- package/dist/db/located-timestamp.d.ts +7 -0
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +8 -2
- package/dist/db/table-builder.d.ts +45 -11
- package/dist/db/tenant-db.js +3 -1
- package/dist/engine/entity-handlers.d.ts +3 -3
- package/dist/engine/factories.d.ts +2 -1
- package/dist/engine/factories.js +2 -0
- package/dist/engine/membership-roles.d.ts +1 -0
- package/dist/engine/membership-roles.js +8 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +30 -2
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +0 -2
- package/dist/files/assert-file-refs-belong-to-tenant.js +27 -1
- package/dist/files/create-file-ref-from-bytes.js +12 -6
- package/dist/files/create-file-ref.d.ts +20 -2
- package/dist/files/create-file-ref.js +10 -1
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +25 -0
- package/dist/files/file-handle.d.ts +4 -1
- package/dist/files/file-handle.js +30 -4
- package/dist/files/file-ref-entity.d.ts +7 -7
- package/dist/files/file-ref-table.d.ts +7 -7
- package/dist/files/file-routes.js +6 -1
- package/dist/files/index.d.ts +3 -1
- package/dist/files/index.js +3 -1
- package/dist/files/provider-resolver.js +3 -1
- package/dist/files/sweep-orphaned-uploads.d.ts +16 -0
- package/dist/files/sweep-orphaned-uploads.js +53 -0
- package/dist/files/upload-ledger.d.ts +10 -0
- package/dist/files/upload-ledger.js +47 -0
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/request-locale.d.ts +15 -4
- package/dist/i18n/request-locale.js +26 -9
- package/dist/jobs/job-runner.js +2 -4
- package/dist/pipeline/dispatch-shared.js +2 -4
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/rate-limit/index.d.ts +1 -0
- package/dist/rate-limit/index.js +1 -0
- package/dist/rate-limit/unlimited-resolver.d.ts +2 -0
- package/dist/rate-limit/unlimited-resolver.js +21 -0
- package/dist/schema-cli.js +46 -5
- package/package.json +5 -5
- package/src/changes.json +42 -0
- package/src/scripts/codemod/README.md +22 -0
- package/src/scripts/codemod/remove-engine-payload-casts.ts +318 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
2
|
import { type EventStoreExecutor } from "../db/event-store-executor.ts";
|
|
3
|
-
import { type EntityTable } from "../db/table-builder.ts";
|
|
3
|
+
import { type EntityRow, type EntityTable } from "../db/table-builder.ts";
|
|
4
4
|
import type { EntityCrudRegistrar, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions } from "./types/entity-handlers.ts";
|
|
5
5
|
import type { AccessRule, EntityDefinition, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "./types/index.ts";
|
|
6
6
|
export type { EntityCrudHandlerDefaults, EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "./types/entity-handlers.ts";
|
|
@@ -53,9 +53,9 @@ export declare function defineEntityDeleteHandler(entityName: string, entity: En
|
|
|
53
53
|
export declare function defineEntityRestoreHandler(entityName: string, entity: EntityDefinition, options: EntityHandlerOptions): WriteHandlerDef;
|
|
54
54
|
export declare function defineEntityListHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
55
55
|
export declare function defineEntityDetailHandler(entityName: string, entity: EntityDefinition, options: EntityQueryHandlerOptions): QueryHandlerDef;
|
|
56
|
-
export declare function createEntityExecutor(entityName: string, entity:
|
|
56
|
+
export declare function createEntityExecutor<E extends EntityDefinition>(entityName: string, entity: E): {
|
|
57
57
|
readonly table: EntityTable;
|
|
58
|
-
readonly executor: EventStoreExecutor
|
|
58
|
+
readonly executor: EventStoreExecutor<EntityRow<E>>;
|
|
59
59
|
};
|
|
60
60
|
export declare function defineProjectionQueryHandler(name: string, projectionQualifiedName: string, options: {
|
|
61
61
|
access: AccessRule;
|
|
@@ -32,6 +32,7 @@ export declare function createBooleanField<R extends true | false = false>(overr
|
|
|
32
32
|
required?: R;
|
|
33
33
|
}): BooleanFieldDef & {
|
|
34
34
|
required: R;
|
|
35
|
+
default: boolean;
|
|
35
36
|
};
|
|
36
37
|
export declare function createSelectField<const TOptions extends readonly string[], R extends true | false = false>(opts: {
|
|
37
38
|
options: TOptions;
|
|
@@ -171,7 +172,7 @@ export declare function createFileField(overrides?: Partial<Omit<FileFieldDef, "
|
|
|
171
172
|
export declare function createImageField(overrides?: Partial<Omit<ImageFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): ImageFieldDef;
|
|
172
173
|
export declare function createFilesField(overrides?: Partial<Omit<FilesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): FilesFieldDef;
|
|
173
174
|
export declare function createImagesField(overrides?: Partial<Omit<ImagesFieldDef, "type" | keyof ResolvedPiiFlags>> & PersonalAnnotationsNoFind): ImagesFieldDef;
|
|
174
|
-
export declare function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
|
|
175
|
+
export declare function createEntity<const F, const T extends EntityTenancy = "tenant">(def: {
|
|
175
176
|
readonly table?: string;
|
|
176
177
|
readonly fields: F;
|
|
177
178
|
readonly description?: string;
|
package/dist/engine/factories.js
CHANGED
|
@@ -378,6 +378,8 @@ export function createImagesField(overrides) {
|
|
|
378
378
|
// Issue um Constraint-Inferenz auf strukturellen Records. Stattdessen
|
|
379
379
|
// validieren wir die Conformance via conditional return type: wenn F
|
|
380
380
|
// nicht FieldsMap-compat ist, kollabiert der Return zu `never`.
|
|
381
|
+
// `const F`: inline field literals (`{ type: "reference", ... }`) keep their
|
|
382
|
+
// `type` literal without `as const`; otherwise it widens to string → `never`.
|
|
381
383
|
export function createEntity(def) {
|
|
382
384
|
return {
|
|
383
385
|
softDelete: false,
|
|
@@ -2,4 +2,5 @@ export declare const FORBIDDEN_MEMBERSHIP_ROLES: ReadonlySet<string>;
|
|
|
2
2
|
export declare function isForbiddenMembershipRole(role: string): boolean;
|
|
3
3
|
export declare function findForbiddenMembershipRole(roles: readonly string[]): string | undefined;
|
|
4
4
|
export declare function stripForbiddenMembershipRoles(roles: readonly string[]): readonly string[];
|
|
5
|
+
export declare function stripReservedTokenRoles(roles: readonly string[]): readonly string[];
|
|
5
6
|
export declare function buildSessionRoles(globalRoles: readonly string[], membershipRoles: readonly string[]): readonly string[];
|
|
@@ -38,6 +38,14 @@ const NON_MINTABLE_GLOBAL_ROLES = new Set([
|
|
|
38
38
|
function stripNonMintableGlobalRoles(roles) {
|
|
39
39
|
return roles.filter((role) => !NON_MINTABLE_GLOBAL_ROLES.has(role));
|
|
40
40
|
}
|
|
41
|
+
// Applied on JWT verify: a token signed with the right key (leaked or foreign)
|
|
42
|
+
// must not carry the in-process System identity or the "all" pseudo-role.
|
|
43
|
+
// "anonymous" stays: it only narrows access (openToAll excludes it), and
|
|
44
|
+
// stripping it would turn the token into an authenticated user with no roles.
|
|
45
|
+
const RESERVED_TOKEN_ROLES = new Set([...access.all, SYSTEM_ROLE]);
|
|
46
|
+
export function stripReservedTokenRoles(roles) {
|
|
47
|
+
return roles.filter((role) => !RESERVED_TOKEN_ROLES.has(role));
|
|
48
|
+
}
|
|
41
49
|
// Single mint path for session roles: merges globalRoles with the stripped
|
|
42
50
|
// membership portion and dedupes. Every place that builds a session's roles
|
|
43
51
|
// from a membership should go through this instead of repeating
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Tenant-level counterpart to projectAppSchemaForRoles: removes the UI surface
|
|
2
2
|
// (screens, navs, content collections, workspaces) of features the tenant has
|
|
3
3
|
// switched off via r.toggleable(). Entities and translations stay — enabled
|
|
4
|
-
// features may still reference them.
|
|
4
|
+
// features may still reference them. Screens that declare a `fallback` (plus
|
|
5
|
+
// the same-feature screens those fallbacks name) are the exception: their
|
|
6
|
+
// visibleWhen probe is rejected by the feature gate for this tenant, which is
|
|
7
|
+
// what makes the renderer show the fallback (e.g. an upgrade notice). Dropping
|
|
8
|
+
// them would also drop every nav of another feature pointing at them, so the
|
|
9
|
+
// fallback could never be reached. Run BEFORE the role projection: it keeps
|
|
5
10
|
// only targets that exist in the schema, so once the disabled screens/navs are
|
|
6
11
|
// gone it also drops every action, redirect, nav and workspace member that
|
|
7
12
|
// pointed at them. The one reference it does not resolve is a nav `parent`
|
|
8
13
|
// pointing at a missing nav; that is pruned here.
|
|
14
|
+
import { resolveScreenTargetQn } from "./boot-validator/screens.js";
|
|
9
15
|
import { isToggleableFeature } from "./effective-features.js";
|
|
10
16
|
import { isValidQn, qualifyEntityName } from "./qualified-name.js";
|
|
11
17
|
// Sorted, so the result doubles as a stable cache key. The trial gate is
|
|
@@ -50,6 +56,28 @@ function collectOrphanedNavQns(features, removedNavQns) {
|
|
|
50
56
|
}
|
|
51
57
|
return orphaned;
|
|
52
58
|
}
|
|
59
|
+
// Fail-closed for the kept screens: the server gate rejects their handlers and
|
|
60
|
+
// queries for the tenant, only the schema entry stays so the fallback can render.
|
|
61
|
+
function screensKeptForFallback(feature) {
|
|
62
|
+
const qnOf = (screenId) => qualifyEntityName(feature.featureName, "screen", screenId);
|
|
63
|
+
const byQn = new Map(feature.screens.map((screen) => [qnOf(screen.id), screen]));
|
|
64
|
+
const kept = new Set();
|
|
65
|
+
const pending = feature.screens.filter((screen) => screen.fallback !== undefined);
|
|
66
|
+
for (const screen of pending)
|
|
67
|
+
kept.add(qnOf(screen.id));
|
|
68
|
+
while (pending.length > 0) {
|
|
69
|
+
const fallback = pending.pop()?.fallback;
|
|
70
|
+
if (fallback === undefined)
|
|
71
|
+
continue;
|
|
72
|
+
const targetQn = resolveScreenTargetQn(feature.featureName, fallback);
|
|
73
|
+
const target = byQn.get(targetQn);
|
|
74
|
+
if (target === undefined || kept.has(targetQn))
|
|
75
|
+
continue;
|
|
76
|
+
kept.add(targetQn);
|
|
77
|
+
pending.push(target);
|
|
78
|
+
}
|
|
79
|
+
return feature.screens.filter((screen) => kept.has(qnOf(screen.id)));
|
|
80
|
+
}
|
|
53
81
|
export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFeatureNames) {
|
|
54
82
|
if (disabledFeatureNames.length === 0)
|
|
55
83
|
return schema;
|
|
@@ -66,7 +94,7 @@ export function stripDisabledFeaturesFromAppSchema(schema, registry, disabledFea
|
|
|
66
94
|
const features = schema.features.map((feature) => {
|
|
67
95
|
if (disabled.has(feature.featureName)) {
|
|
68
96
|
const { navs: _navs, contentCollections: _collections, workspaces: _ws, ...rest } = feature;
|
|
69
|
-
return { ...rest, screens:
|
|
97
|
+
return { ...rest, screens: screensKeptForFallback(feature) };
|
|
70
98
|
}
|
|
71
99
|
if (orphanedNavQns.size === 0 || feature.navs === undefined)
|
|
72
100
|
return feature;
|
|
@@ -17,6 +17,7 @@ export type BaseEntrypointOptions = {
|
|
|
17
17
|
readonly jwtSecret: string | JwtKeyring;
|
|
18
18
|
readonly jwtIssuer?: string;
|
|
19
19
|
readonly observability?: ObservabilityProvider;
|
|
20
|
+
/** @deprecated No effect: the HTTP route label carries no query string anymore, so there is nothing left to configure. */
|
|
20
21
|
readonly observabilityOptions?: ObservabilityOptions;
|
|
21
22
|
readonly dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
22
23
|
readonly systemHooks?: SystemHooks;
|
package/dist/entrypoint/index.js
CHANGED
|
@@ -127,7 +127,6 @@ function buildApiServer(opts, lifecycle, dispatcherOverride, jobRunner, processL
|
|
|
127
127
|
metrics: opts.metrics,
|
|
128
128
|
extraRoutes: opts.extraRoutes,
|
|
129
129
|
observability: opts.observability,
|
|
130
|
-
observabilityOptions: opts.observabilityOptions,
|
|
131
130
|
dispatcherOptions,
|
|
132
131
|
systemHooks: opts.systemHooks,
|
|
133
132
|
eventDedup: opts.eventDedup,
|
|
@@ -147,7 +146,6 @@ function buildWorkerServer(opts, lifecycle, jobRunner) {
|
|
|
147
146
|
lifecycle,
|
|
148
147
|
processLane: "worker",
|
|
149
148
|
observability: opts.observability,
|
|
150
|
-
observabilityOptions: opts.observabilityOptions,
|
|
151
149
|
dispatcherOptions,
|
|
152
150
|
systemHooks: opts.systemHooks,
|
|
153
151
|
eventDedup: opts.eventDedup,
|
|
@@ -10,6 +10,19 @@ import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
|
10
10
|
// cannot be probed across tenants. `fileRefRows` holds only the writer's own
|
|
11
11
|
// tenant's live rows, so a missing entry is exactly those two cases. Values
|
|
12
12
|
// that are not UUIDs are skipped by fileRefIdsOf and never reach this check.
|
|
13
|
+
//
|
|
14
|
+
// Within the tenant, a newly named id must not be bound to a different record:
|
|
15
|
+
// the file routes authorize through the record a file is bound to, so naming
|
|
16
|
+
// record A's file in record B would hand B's readers A's file (and a later
|
|
17
|
+
// removal from B would not touch it). Pending uploads (no entityId), ids the
|
|
18
|
+
// record already holds in any file field (checked first, even if their row is gone)
|
|
19
|
+
// and moves between its own fields stay allowed. The error is the same as above so the owning record is not revealed.
|
|
20
|
+
function isBoundToOtherRecord(row, entityName, recordId) {
|
|
21
|
+
const boundId = row["entityId"];
|
|
22
|
+
if (boundId === null || boundId === undefined)
|
|
23
|
+
return false;
|
|
24
|
+
return row["entityType"] !== entityName || boundId !== recordId;
|
|
25
|
+
}
|
|
13
26
|
export function assertFileRefsBelongToTenant(args) {
|
|
14
27
|
const { registry, result, fileRefRows } = args;
|
|
15
28
|
// skip: saves of unregistered aggregates (custom executors) declare no file fields
|
|
@@ -22,11 +35,24 @@ export function assertFileRefsBelongToTenant(args) {
|
|
|
22
35
|
// skip: without the files feature the file_refs table is not part of the schema
|
|
23
36
|
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
24
37
|
return;
|
|
38
|
+
const recordId = String(result.id);
|
|
39
|
+
const alreadyHeld = new Set();
|
|
40
|
+
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
41
|
+
if (!isFileField(fieldDef))
|
|
42
|
+
continue;
|
|
43
|
+
for (const fileRefId of fileRefIdsOf(result.previous[fieldName]))
|
|
44
|
+
alreadyHeld.add(fileRefId);
|
|
45
|
+
}
|
|
25
46
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
26
47
|
if (!isFileField(fieldDef))
|
|
27
48
|
continue;
|
|
28
49
|
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
29
|
-
|
|
50
|
+
// A value the record already holds is no new binding; its file_refs row may be gone
|
|
51
|
+
// (forget purged the file), and sending the unchanged field back must still save.
|
|
52
|
+
if (alreadyHeld.has(fileRefId))
|
|
53
|
+
continue;
|
|
54
|
+
const row = fileRefRows.get(fileRefId);
|
|
55
|
+
if (row !== undefined && !isBoundToOtherRecord(row, result.entityName, recordId))
|
|
30
56
|
continue;
|
|
31
57
|
throw new ValidationError({
|
|
32
58
|
fields: [
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { fetchOne } from "../db/query.js";
|
|
1
|
+
import { fetchOne, runnerCanBeginTransaction } from "../db/query.js";
|
|
2
|
+
import { tenantDbRunner } from "../db/tenant-db-runner.js";
|
|
2
3
|
import { FrameworkReasons, InternalError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
3
4
|
import { createFileRefRow, DEFAULT_MAX_UPLOAD_SIZE, storeFileAsFileRef, writeAcceptedFileBytes, } from "./create-file-ref.js";
|
|
4
5
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
@@ -22,9 +23,10 @@ export async function createFileRefFromBytes(ctx, input) {
|
|
|
22
23
|
const outcome = await storeFileAsFileRef({
|
|
23
24
|
user: ctx.user,
|
|
24
25
|
tenantDb: ctx.db,
|
|
26
|
+
rowInOwnTransaction: runnerCanBeginTransaction(tenantDbRunner(ctx.db)),
|
|
25
27
|
registry: ctx.registry,
|
|
26
28
|
defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
27
|
-
writeBytes: (key, data, mimeType) => files.
|
|
29
|
+
writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
|
|
28
30
|
deleteBytes: (key) => files.ref(key).delete(),
|
|
29
31
|
}, {
|
|
30
32
|
fileName: input.fileName,
|
|
@@ -48,7 +50,8 @@ function fileRejectedFailure(error) {
|
|
|
48
50
|
}
|
|
49
51
|
// Pre-transaction half of createFileRefFromBytes: validates and writes the binary while no
|
|
50
52
|
// transaction holds a connection. Pair it with createFileRefFromUploadedBytes inside the handler.
|
|
51
|
-
// A crash between this phase and the commit leaves the bytes in storage;
|
|
53
|
+
// A crash between this phase and the commit leaves the bytes in storage; the upload ledger sweep
|
|
54
|
+
// removes them after its grace period.
|
|
52
55
|
export async function uploadFileBytesBeforeTransaction(ctx, input) {
|
|
53
56
|
const { files } = ctx;
|
|
54
57
|
if (!files) {
|
|
@@ -60,7 +63,7 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
|
|
|
60
63
|
user: ctx.user,
|
|
61
64
|
registry: ctx.registry,
|
|
62
65
|
defaultMaxUploadSize: readFilesRouteOptions(ctx.registry).maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
63
|
-
writeBytes: (key, data, mimeType) => files.
|
|
66
|
+
writeBytes: (key, data, mimeType) => files.writeUploadBytes(key, data, mimeType),
|
|
64
67
|
}, {
|
|
65
68
|
fileName: input.fileName,
|
|
66
69
|
declaredMimeType: input.mimeType,
|
|
@@ -80,8 +83,11 @@ export async function uploadFileBytesBeforeTransaction(ctx, input) {
|
|
|
80
83
|
const committedRow = await fetchOne(ctx.db, fileRefsTable, {
|
|
81
84
|
storageKey: uploaded.storageKey,
|
|
82
85
|
});
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
// skip: a committed row owns the bytes, the commit already took the ledger entry
|
|
87
|
+
if (committedRow)
|
|
88
|
+
return;
|
|
89
|
+
await files.ref(uploaded.storageKey).delete();
|
|
90
|
+
await files.forgetUploadBytes(uploaded.storageKey);
|
|
85
91
|
},
|
|
86
92
|
};
|
|
87
93
|
}
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { type TenantDb } from "../db/tenant-db.ts";
|
|
2
2
|
import { type AnyFileFieldDef, type Registry, type SessionUser } from "../engine/types/index.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { type WriteFailure } from "../errors/index.ts";
|
|
4
4
|
export declare const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
5
|
-
declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor
|
|
5
|
+
declare function buildFileRefExecutor(): import("@cosmicdrift/kumiko-types/event-store-executor-types").EventStoreExecutor<{
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly tenantId: string;
|
|
8
|
+
readonly version: number;
|
|
9
|
+
readonly insertedAt: Temporal.Instant;
|
|
10
|
+
readonly modifiedAt: Temporal.Instant | null;
|
|
11
|
+
readonly insertedById: string | null;
|
|
12
|
+
readonly modifiedById: string | null;
|
|
13
|
+
readonly isDeleted?: boolean | undefined;
|
|
14
|
+
readonly deletedAt?: Temporal.Instant | null | undefined;
|
|
15
|
+
readonly deletedById?: string | null | undefined;
|
|
16
|
+
readonly entityId: string | null;
|
|
17
|
+
readonly entityType: string | null;
|
|
18
|
+
readonly fieldName: string | null;
|
|
19
|
+
readonly fileName: string;
|
|
20
|
+
readonly mimeType: string;
|
|
21
|
+
readonly size: unknown;
|
|
22
|
+
readonly storageKey: string;
|
|
23
|
+
}>;
|
|
6
24
|
export declare function getFileRefExecutor(): ReturnType<typeof buildFileRefExecutor>;
|
|
7
25
|
type AttachedFieldResolution = {
|
|
8
26
|
readonly kind: "unattached";
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "../db/event-store-executor.js";
|
|
2
2
|
import { runInOwnTransaction } from "../db/tenant-db.js";
|
|
3
3
|
import { isFileField, } from "../engine/types/index.js";
|
|
4
|
+
import { InternalError } from "../errors/index.js";
|
|
4
5
|
import { generateId } from "../utils/index.js";
|
|
5
6
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
6
7
|
import { fileRefsTable } from "./file-ref-table.js";
|
|
7
8
|
import { resolveContentType } from "./resolve-content-type.js";
|
|
8
9
|
import { buildStorageKey, resolveUploadMimeType, validateFile, validateFileContent, } from "./types.js";
|
|
10
|
+
import { takeUploadLedgerEntry } from "./upload-ledger.js";
|
|
9
11
|
export const DEFAULT_MAX_UPLOAD_SIZE = "10mb";
|
|
10
12
|
// Standard entity executor for fileRef: create/delete emit fileRef.created /
|
|
11
13
|
// fileRef.deleted and write file_refs via applyEntityEvent in one tx
|
|
@@ -132,6 +134,13 @@ export async function createFileRefRow(deps, uploaded) {
|
|
|
132
134
|
}, deps.user, deps.tenantDb);
|
|
133
135
|
if (!result.isSuccess)
|
|
134
136
|
return { failure: result };
|
|
137
|
+
// Throws instead of returning a failure: a returned failure would still commit in the
|
|
138
|
+
// own-transaction path, leaving a fileRef row whose bytes the sweep already deleted.
|
|
139
|
+
if (!(await takeUploadLedgerEntry(deps.tenantDb, uploaded.storageKey))) {
|
|
140
|
+
throw new InternalError({
|
|
141
|
+
message: "upload expired: the bytes were swept before the fileRef row was created, upload again",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
135
144
|
return {
|
|
136
145
|
fileRef: {
|
|
137
146
|
id: fileRefId,
|
|
@@ -148,7 +157,7 @@ export async function storeFileAsFileRef(deps, input) {
|
|
|
148
157
|
if (upload.kind === "rejected")
|
|
149
158
|
return upload;
|
|
150
159
|
// A failed create deletes the bytes again; a later rollback of an enclosing transaction or a
|
|
151
|
-
// crash cannot, so
|
|
160
|
+
// crash cannot, so the upload ledger sweep removes those bytes after its grace period.
|
|
152
161
|
// Losing a row on append-failure is acceptable; a committed row with a missing binary is not.
|
|
153
162
|
// A throwing projection on fileRef.created fails the create by exception, not by WriteFailure.
|
|
154
163
|
const deleteUploadedBytes = async () => {
|
package/dist/files/feature.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type FilesFeatureOptions = {
|
|
|
6
6
|
readonly privilegedRoles?: readonly string[];
|
|
7
7
|
readonly unclaimedUploadReaders?: readonly string[];
|
|
8
8
|
readonly maxUploadSize?: string;
|
|
9
|
+
readonly uploadSweepGraceHours?: number;
|
|
9
10
|
};
|
|
10
11
|
export declare const FILES_FEATURE: "files";
|
|
11
12
|
export declare function createFilesFeature(options?: FilesFeatureOptions): FeatureDefinition;
|
package/dist/files/feature.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineFeature } from "../engine/index.js";
|
|
2
2
|
import { bindReferencedFilesWrite } from "./bind-referenced-files.write.js";
|
|
3
3
|
import { FILE_REF_ENTITY_NAME, fileRefEntity } from "./file-ref-entity.js";
|
|
4
|
+
import { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
|
|
5
|
+
import { fileUploadLedgerTable } from "./upload-ledger.js";
|
|
4
6
|
export { fileRefEntity } from "./file-ref-entity.js";
|
|
5
7
|
// files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
|
|
6
8
|
// den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
|
|
@@ -14,7 +16,14 @@ export { fileRefEntity } from "./file-ref-entity.js";
|
|
|
14
16
|
// Entity-Projektion (für rebuildProjection). Liegt im Framework neben
|
|
15
17
|
// file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
|
|
16
18
|
export const FILES_FEATURE = "files";
|
|
19
|
+
function assertValidSweepGraceHours(hours) {
|
|
20
|
+
if (!Number.isInteger(hours) || hours < MIN_UPLOAD_SWEEP_GRACE_HOURS) {
|
|
21
|
+
throw new Error(`createFilesFeature: uploadSweepGraceHours must be a whole number of hours >= ${MIN_UPLOAD_SWEEP_GRACE_HOURS}, got ${String(hours)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
17
24
|
export function createFilesFeature(options = {}) {
|
|
25
|
+
const uploadSweepGraceHours = options.uploadSweepGraceHours ?? DEFAULT_UPLOAD_SWEEP_GRACE_HOURS;
|
|
26
|
+
assertValidSweepGraceHours(uploadSweepGraceHours);
|
|
18
27
|
return defineFeature(FILES_FEATURE, (r) => {
|
|
19
28
|
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?)`.");
|
|
20
29
|
r.uiHints({
|
|
@@ -24,6 +33,22 @@ export function createFilesFeature(options = {}) {
|
|
|
24
33
|
});
|
|
25
34
|
r.entity({ name: FILE_REF_ENTITY_NAME, ...fileRefEntity });
|
|
26
35
|
r.writeHandler(bindReferencedFilesWrite);
|
|
36
|
+
r.storeTable(fileUploadLedgerTable, { reason: "coordination.file_upload_ledger" });
|
|
37
|
+
r.job({
|
|
38
|
+
name: "sweep-orphaned-uploads",
|
|
39
|
+
trigger: { cron: "0 * * * *" },
|
|
40
|
+
perTenant: true,
|
|
41
|
+
concurrency: "skip",
|
|
42
|
+
handler: async (_payload, ctx) => {
|
|
43
|
+
await sweepOrphanedUploads({
|
|
44
|
+
db: ctx.db,
|
|
45
|
+
files: ctx.files,
|
|
46
|
+
now: ctx.tz.now(),
|
|
47
|
+
graceHours: uploadSweepGraceHours,
|
|
48
|
+
log: ctx.log,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
27
52
|
return { routeOptions: options };
|
|
28
53
|
});
|
|
29
54
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { FileContext, FileHandle } from "@cosmicdrift/kumiko-types/file-handle-types";
|
|
2
|
+
import type { FileProviderResolver, FileUploadLedger } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
3
|
+
import type { TenantId } from "../engine/types/identifiers.ts";
|
|
2
4
|
import { type FileStorageProvider } from "./types.ts";
|
|
3
5
|
export type { FileContext, FileHandle };
|
|
4
6
|
export declare function createFileHandle(key: string, getProvider: () => Promise<FileStorageProvider>): FileHandle;
|
|
5
|
-
export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string): FileContext;
|
|
7
|
+
export declare function createFileContext(resolve: () => Promise<FileStorageProvider>, tenantId?: string, uploadLedger?: FileUploadLedger): FileContext;
|
|
8
|
+
export declare function createTenantFileContext(resolver: FileProviderResolver, tenantId: TenantId): FileContext;
|
|
6
9
|
export declare function deriveKey(key: string, suffix: string): string;
|
|
7
10
|
export declare function storageKeyStemPrefix(key: string): string;
|
|
@@ -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 { InternalError } from "../errors/index.js";
|
|
14
15
|
import { assertSafeStorageKey } from "./types.js";
|
|
15
16
|
// `getProvider` is a lazily-resolved, memoized accessor — the provider is
|
|
16
17
|
// resolved (config + s3.secretAccessKey secret read) only when a handle method
|
|
@@ -42,19 +43,39 @@ export function createFileHandle(key, getProvider) {
|
|
|
42
43
|
// With `tenantId`, `ref` and `list` only accept keys/prefixes inside `${tenantId}/` —
|
|
43
44
|
// the key space is the isolation boundary in a store shared by several tenants, and
|
|
44
45
|
// a request-derived key or prefix (or "") would otherwise reach every tenant's keys.
|
|
45
|
-
export function createFileContext(resolve, tenantId) {
|
|
46
|
+
export function createFileContext(resolve, tenantId, uploadLedger) {
|
|
46
47
|
let cached;
|
|
47
48
|
const getProvider = () => {
|
|
48
49
|
cached ??= resolve();
|
|
49
50
|
return cached;
|
|
50
51
|
};
|
|
52
|
+
const requireUploadLedger = () => {
|
|
53
|
+
if (uploadLedger === undefined) {
|
|
54
|
+
throw new InternalError({
|
|
55
|
+
message: "FileContext: no upload ledger is wired, so upload bytes would be unrecoverable after a crash. Build the context from a resolver made by withFileProviderResolver (or withUploadLedger)",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return uploadLedger;
|
|
59
|
+
};
|
|
60
|
+
const assertOwnKey = (method, key) => {
|
|
61
|
+
if (tenantId !== undefined && !key.startsWith(`${tenantId}/`)) {
|
|
62
|
+
throw new Error(`FileContext.${method}: key must start with "${tenantId}/"`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
51
65
|
return {
|
|
52
66
|
ref: (key) => {
|
|
53
|
-
|
|
54
|
-
throw new Error(`FileContext.ref: key must start with "${tenantId}/"`);
|
|
55
|
-
}
|
|
67
|
+
assertOwnKey("ref", key);
|
|
56
68
|
return createFileHandle(key, getProvider);
|
|
57
69
|
},
|
|
70
|
+
writeUploadBytes: async (key, data, mimeType) => {
|
|
71
|
+
assertOwnKey("writeUploadBytes", key);
|
|
72
|
+
await requireUploadLedger().record(key);
|
|
73
|
+
await (await getProvider()).write(key, data, mimeType);
|
|
74
|
+
},
|
|
75
|
+
forgetUploadBytes: async (key) => {
|
|
76
|
+
assertOwnKey("forgetUploadBytes", key);
|
|
77
|
+
await requireUploadLedger().forget(key);
|
|
78
|
+
},
|
|
58
79
|
/** Validates only the storage key shape (assertSafeStorageKey), no tenant prefix: keys
|
|
59
80
|
* may outlive a tenant handover. The caller must ensure the fileRef row came from a
|
|
60
81
|
* tenant-filtered read. */
|
|
@@ -74,6 +95,11 @@ export function createFileContext(resolve, tenantId) {
|
|
|
74
95
|
},
|
|
75
96
|
};
|
|
76
97
|
}
|
|
98
|
+
// The one way to build a tenant's FileContext from a resolver: carries the resolver's upload
|
|
99
|
+
// ledger, so every context agrees on where upload bytes are tracked.
|
|
100
|
+
export function createTenantFileContext(resolver, tenantId) {
|
|
101
|
+
return createFileContext(() => resolver(tenantId), tenantId, resolver.uploadLedgerFor?.(tenantId));
|
|
102
|
+
}
|
|
77
103
|
// Splits a key into the part before the last dot of its last path segment
|
|
78
104
|
// (the "stem") and the extension including the dot. No dot in the last
|
|
79
105
|
// segment: the whole key is the stem, extension is empty.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { FILE_REF_ENTITY_NAME } from "./types.ts";
|
|
2
2
|
export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
|
|
3
|
-
storageKey: {
|
|
3
|
+
readonly storageKey: {
|
|
4
4
|
readonly type: "text";
|
|
5
5
|
readonly description?: string;
|
|
6
6
|
readonly maxLength?: number;
|
|
@@ -23,7 +23,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
23
23
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
24
24
|
required: true;
|
|
25
25
|
};
|
|
26
|
-
fileName: {
|
|
26
|
+
readonly fileName: {
|
|
27
27
|
readonly type: "text";
|
|
28
28
|
readonly description?: string;
|
|
29
29
|
readonly maxLength?: number;
|
|
@@ -46,7 +46,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
46
46
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
47
47
|
required: true;
|
|
48
48
|
};
|
|
49
|
-
mimeType: {
|
|
49
|
+
readonly mimeType: {
|
|
50
50
|
readonly type: "text";
|
|
51
51
|
readonly description?: string;
|
|
52
52
|
readonly maxLength?: number;
|
|
@@ -69,7 +69,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
69
69
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
70
70
|
required: true;
|
|
71
71
|
};
|
|
72
|
-
size: {
|
|
72
|
+
readonly size: {
|
|
73
73
|
readonly type: "bigInt";
|
|
74
74
|
readonly description?: string;
|
|
75
75
|
readonly required?: boolean;
|
|
@@ -81,7 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
81
81
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
82
82
|
required: true;
|
|
83
83
|
};
|
|
84
|
-
entityType: {
|
|
84
|
+
readonly entityType: {
|
|
85
85
|
readonly type: "text";
|
|
86
86
|
readonly description?: string;
|
|
87
87
|
readonly maxLength?: number;
|
|
@@ -104,7 +104,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
104
104
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
105
105
|
required: false;
|
|
106
106
|
};
|
|
107
|
-
entityId: {
|
|
107
|
+
readonly entityId: {
|
|
108
108
|
readonly type: "text";
|
|
109
109
|
readonly description?: string;
|
|
110
110
|
readonly maxLength?: number;
|
|
@@ -127,7 +127,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
|
|
|
127
127
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
128
128
|
required: false;
|
|
129
129
|
};
|
|
130
|
-
fieldName: {
|
|
130
|
+
readonly fieldName: {
|
|
131
131
|
readonly type: "text";
|
|
132
132
|
readonly description?: string;
|
|
133
133
|
readonly maxLength?: number;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable<import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
|
|
2
|
-
storageKey: {
|
|
2
|
+
readonly storageKey: {
|
|
3
3
|
readonly type: "text";
|
|
4
4
|
readonly description?: string;
|
|
5
5
|
readonly maxLength?: number;
|
|
@@ -22,7 +22,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
22
22
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
23
23
|
required: true;
|
|
24
24
|
};
|
|
25
|
-
fileName: {
|
|
25
|
+
readonly fileName: {
|
|
26
26
|
readonly type: "text";
|
|
27
27
|
readonly description?: string;
|
|
28
28
|
readonly maxLength?: number;
|
|
@@ -45,7 +45,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
45
45
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
46
46
|
required: true;
|
|
47
47
|
};
|
|
48
|
-
mimeType: {
|
|
48
|
+
readonly mimeType: {
|
|
49
49
|
readonly type: "text";
|
|
50
50
|
readonly description?: string;
|
|
51
51
|
readonly maxLength?: number;
|
|
@@ -68,7 +68,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
68
68
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
69
69
|
required: true;
|
|
70
70
|
};
|
|
71
|
-
size: {
|
|
71
|
+
readonly size: {
|
|
72
72
|
readonly type: "bigInt";
|
|
73
73
|
readonly description?: string;
|
|
74
74
|
readonly required?: boolean;
|
|
@@ -80,7 +80,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
80
80
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
81
81
|
required: true;
|
|
82
82
|
};
|
|
83
|
-
entityType: {
|
|
83
|
+
readonly entityType: {
|
|
84
84
|
readonly type: "text";
|
|
85
85
|
readonly description?: string;
|
|
86
86
|
readonly maxLength?: number;
|
|
@@ -103,7 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
103
103
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
104
104
|
required: false;
|
|
105
105
|
};
|
|
106
|
-
entityId: {
|
|
106
|
+
readonly entityId: {
|
|
107
107
|
readonly type: "text";
|
|
108
108
|
readonly description?: string;
|
|
109
109
|
readonly maxLength?: number;
|
|
@@ -126,7 +126,7 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
|
|
|
126
126
|
} & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
|
|
127
127
|
required: false;
|
|
128
128
|
};
|
|
129
|
-
fieldName: {
|
|
129
|
+
readonly fieldName: {
|
|
130
130
|
readonly type: "text";
|
|
131
131
|
readonly description?: string;
|
|
132
132
|
readonly maxLength?: number;
|
|
@@ -16,6 +16,7 @@ import { fileRefsTable } from "./file-ref-table.js";
|
|
|
16
16
|
import { RecordWriteDecisions } from "./record-write-check.js";
|
|
17
17
|
import { neutralFileNameForMimeType } from "./resolve-content-type.js";
|
|
18
18
|
import { parseMaxSize, resolveServedContentType } from "./types.js";
|
|
19
|
+
import { createFileUploadLedger } from "./upload-ledger.js";
|
|
19
20
|
const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
|
|
20
21
|
// 15 minutes — long enough for a download to start, short enough that a
|
|
21
22
|
// leaked URL (e.g. from a browser history screenshot) isn't a long-lived
|
|
@@ -171,13 +172,17 @@ export function createFileRoutes(options) {
|
|
|
171
172
|
}
|
|
172
173
|
const data = new Uint8Array(await file.arrayBuffer());
|
|
173
174
|
const storageProvider = await options.resolveProvider(user.tenantId);
|
|
175
|
+
const uploadLedger = createFileUploadLedger(db, user.tenantId);
|
|
174
176
|
const outcome = await storeFileAsFileRef({
|
|
175
177
|
user,
|
|
176
178
|
tenantDb: tenantDbFor(user.tenantId),
|
|
177
179
|
rowInOwnTransaction: true,
|
|
178
180
|
registry: options.registry,
|
|
179
181
|
defaultMaxUploadSize: options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE,
|
|
180
|
-
writeBytes: (key, bytes, mimeType) =>
|
|
182
|
+
writeBytes: async (key, bytes, mimeType) => {
|
|
183
|
+
await uploadLedger.record(key);
|
|
184
|
+
await storageProvider.write(key, bytes, mimeType);
|
|
185
|
+
},
|
|
181
186
|
deleteBytes: (key) => storageProvider.delete(key),
|
|
182
187
|
}, { fileName: file.name, declaredMimeType: file.type, data, entityType, entityId, fieldName });
|
|
183
188
|
if (outcome.kind === "rejected")
|
package/dist/files/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, Uploaded
|
|
|
2
2
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
|
|
3
3
|
export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
|
|
4
4
|
export type { FileContext, FileHandle } from "./file-handle.ts";
|
|
5
|
-
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.ts";
|
|
5
|
+
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.ts";
|
|
6
6
|
export { fileRefEntity } from "./file-ref-entity.ts";
|
|
7
7
|
export { fileRefsTable } from "./file-ref-table.ts";
|
|
8
8
|
export type { FileAccessDecision, FileAccessGuard, FileRef, FileRoutesOptions, } from "./file-routes.ts";
|
|
@@ -14,7 +14,9 @@ export type { FileProviderContext, FileProviderPlugin, FileProviderResolver, Fil
|
|
|
14
14
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.ts";
|
|
15
15
|
export { resolveContentType } from "./resolve-content-type.ts";
|
|
16
16
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.ts";
|
|
17
|
+
export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.ts";
|
|
17
18
|
export type { FileContentValidationResult, FileMetadata, FileStorageProvider, FileValidationOptions, SignedUrlOptions, UploadMimeTypeResolution, WriteStreamOptions, } from "./types.ts";
|
|
18
19
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.ts";
|
|
20
|
+
export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.ts";
|
|
19
21
|
export type { ZipEntry } from "./zip-stream.ts";
|
|
20
22
|
export { createZipStream } from "./zip-stream.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -2,7 +2,7 @@ export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytes
|
|
|
2
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
|
-
export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle.js";
|
|
5
|
+
export { createFileContext, createTenantFileContext, deriveKey, storageKeyStemPrefix, } from "./file-handle.js";
|
|
6
6
|
export { fileRefEntity } from "./file-ref-entity.js";
|
|
7
7
|
export { fileRefsTable } from "./file-ref-table.js";
|
|
8
8
|
export { createFileRoutes, readFilesRouteOptions, resolveMaxUploadBodyBytes, } from "./file-routes.js";
|
|
@@ -11,5 +11,7 @@ export { createLocalProvider } from "./local-provider.js";
|
|
|
11
11
|
export { createFileProviderForTenant, isFileProviderPlugin, makeFileProviderResolver, } from "./provider-resolver.js";
|
|
12
12
|
export { resolveContentType } from "./resolve-content-type.js";
|
|
13
13
|
export { fileRefStorageDelta, filesStorageTrackingFeature, tenantStorageUsageTable, transferTenantStorageUsage, } from "./storage-tracking.js";
|
|
14
|
+
export { DEFAULT_UPLOAD_SWEEP_GRACE_HOURS, MIN_UPLOAD_SWEEP_GRACE_HOURS, sweepOrphanedUploads, } from "./sweep-orphaned-uploads.js";
|
|
14
15
|
export { assertSafeStorageKey, buildStorageKey, normalizeMimeType, parseMaxSize, resolveUploadMimeType, sniffMimeType, tenantExportPrefix, tenantStoragePrefixes, validateFile, validateFileContent, } from "./types.js";
|
|
16
|
+
export { createFileUploadLedger, fileUploadLedgerTable, withUploadLedger, } from "./upload-ledger.js";
|
|
15
17
|
export { createZipStream } from "./zip-stream.js";
|