@podosoft/podokit 1.3.0 → 1.4.1
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/README.md +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import {
|
|
4
|
+
MultipartUploadError,
|
|
5
|
+
type CompletedPart,
|
|
6
|
+
type DatabaseProviderName,
|
|
7
|
+
type ObjectInfo,
|
|
8
|
+
type ObjectStore,
|
|
9
|
+
} from "@podosoft/podokit-runtime";
|
|
10
|
+
import { expectedPartSize, partLayout, type UploadConfig } from "./upload-config";
|
|
11
|
+
import { uploadKey } from "./upload-keys";
|
|
12
|
+
|
|
13
|
+
export type UploadStatus = "open" | "completing" | "completed" | "aborted";
|
|
14
|
+
|
|
15
|
+
export interface NewUpload {
|
|
16
|
+
filename: string;
|
|
17
|
+
size: number;
|
|
18
|
+
contentType?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreatedUpload {
|
|
22
|
+
id: string;
|
|
23
|
+
partSize: number;
|
|
24
|
+
partCount: number;
|
|
25
|
+
expiresAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UploadView extends CreatedUpload {
|
|
29
|
+
status: UploadStatus;
|
|
30
|
+
filename: string;
|
|
31
|
+
contentType: string | null;
|
|
32
|
+
size: number;
|
|
33
|
+
/** Part numbers stored so far, ascending. Resume by sending the others. */
|
|
34
|
+
receivedParts: number[];
|
|
35
|
+
/** The object key, once the upload has completed. */
|
|
36
|
+
key?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface StoredPart {
|
|
40
|
+
partNumber: number;
|
|
41
|
+
size: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CompletedUpload {
|
|
45
|
+
id: string;
|
|
46
|
+
status: "completed";
|
|
47
|
+
key: string;
|
|
48
|
+
size: number;
|
|
49
|
+
contentType: string | null;
|
|
50
|
+
url: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SweepResult {
|
|
54
|
+
/** Open uploads past their expiry that were aborted. */
|
|
55
|
+
expired: number;
|
|
56
|
+
/** Aborted uploads whose storage cleanup was retried successfully. */
|
|
57
|
+
released: number;
|
|
58
|
+
/** Uploads left completing by a stopped replica that were settled. */
|
|
59
|
+
recovered: number;
|
|
60
|
+
/** Finished upload records deleted after their retention. */
|
|
61
|
+
deleted: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ChunkedUploadLogger {
|
|
65
|
+
warn(details: Record<string, unknown>, message: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChunkedUploadsOptions {
|
|
69
|
+
sql: SQL;
|
|
70
|
+
dialect: DatabaseProviderName;
|
|
71
|
+
config: UploadConfig;
|
|
72
|
+
/** Resolved on use, so an object store replaced after configuration is honored. */
|
|
73
|
+
storage: () => ObjectStore;
|
|
74
|
+
logger?: ChunkedUploadLogger;
|
|
75
|
+
now?: () => number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Thrown into a part body that is shorter or longer than its Content-Length promised. */
|
|
79
|
+
class PartLengthError extends Error {}
|
|
80
|
+
|
|
81
|
+
interface SessionRow {
|
|
82
|
+
id: string;
|
|
83
|
+
ownerId: string | null;
|
|
84
|
+
objectKey: string;
|
|
85
|
+
multipartUploadId: string | null;
|
|
86
|
+
filename: string;
|
|
87
|
+
contentType: string | null;
|
|
88
|
+
size: number | string;
|
|
89
|
+
partSize: number | string;
|
|
90
|
+
partCount: number | string;
|
|
91
|
+
status: UploadStatus;
|
|
92
|
+
expiresAt: Date | string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Session {
|
|
96
|
+
id: string;
|
|
97
|
+
ownerId: string | null;
|
|
98
|
+
objectKey: string;
|
|
99
|
+
multipartUploadId: string | null;
|
|
100
|
+
filename: string;
|
|
101
|
+
contentType: string | null;
|
|
102
|
+
size: number;
|
|
103
|
+
partSize: number;
|
|
104
|
+
partCount: number;
|
|
105
|
+
status: UploadStatus;
|
|
106
|
+
expiresAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ClaimedRow {
|
|
110
|
+
id: string;
|
|
111
|
+
objectKey: string;
|
|
112
|
+
multipartUploadId: string | null;
|
|
113
|
+
size: number | string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
117
|
+
/** How long one replica may hold an upload in "completing" before the sweep settles it. */
|
|
118
|
+
export const COMPLETE_LEASE_MS = 15 * 60 * 1000;
|
|
119
|
+
const SWEEP_BATCH = 100;
|
|
120
|
+
const SWEEP_MAX_BATCHES = 10;
|
|
121
|
+
const MAX_LISTED_PARTS = 20;
|
|
122
|
+
|
|
123
|
+
function iso(time: number): string {
|
|
124
|
+
return new Date(time).toISOString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function message(error: unknown): string {
|
|
128
|
+
return error instanceof Error ? error.message : String(error);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function session(row: SessionRow): Session {
|
|
132
|
+
return {
|
|
133
|
+
id: row.id,
|
|
134
|
+
ownerId: row.ownerId,
|
|
135
|
+
objectKey: row.objectKey,
|
|
136
|
+
multipartUploadId: row.multipartUploadId,
|
|
137
|
+
filename: row.filename,
|
|
138
|
+
contentType: row.contentType,
|
|
139
|
+
// PostgreSQL returns bigint columns as strings.
|
|
140
|
+
size: Number(row.size),
|
|
141
|
+
partSize: Number(row.partSize),
|
|
142
|
+
partCount: Number(row.partCount),
|
|
143
|
+
status: row.status,
|
|
144
|
+
expiresAt: new Date(row.expiresAt).getTime(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function notFound(text = "Upload not found"): AppException {
|
|
149
|
+
return new AppException("UPLOAD_NOT_FOUND", text, 404);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Passes the body through while counting it, and fails it unless it holds exactly `expected` bytes. */
|
|
153
|
+
function exactLength(body: ReadableStream<Uint8Array>, expected: number): ReadableStream<Uint8Array> {
|
|
154
|
+
let received = 0;
|
|
155
|
+
return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
|
156
|
+
transform(chunk, controller): void {
|
|
157
|
+
received += chunk.byteLength;
|
|
158
|
+
if (received > expected) throw new PartLengthError("The part body is longer than its Content-Length");
|
|
159
|
+
controller.enqueue(chunk);
|
|
160
|
+
},
|
|
161
|
+
flush(): void {
|
|
162
|
+
if (received !== expected) throw new PartLengthError("The part body ended before its Content-Length");
|
|
163
|
+
},
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function listed(numbers: number[]): string {
|
|
168
|
+
const shown = numbers.slice(0, MAX_LISTED_PARTS).join(", ");
|
|
169
|
+
return numbers.length > MAX_LISTED_PARTS ? `${shown}, and ${numbers.length - MAX_LISTED_PARTS} more` : shown;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resumable uploads assembled from parts. Every state lives in the database and
|
|
174
|
+
* every transition is a conditional UPDATE, so any API replica can serve any
|
|
175
|
+
* request of an upload, and only one replica completes or aborts it.
|
|
176
|
+
*
|
|
177
|
+
* States: open (accepting parts) -> completing (one replica is assembling the
|
|
178
|
+
* object) -> completed; open -> aborted by the owner or by the expiry sweep.
|
|
179
|
+
* `multipartUploadId` stays set until the storage upload is completed or
|
|
180
|
+
* aborted, so the sweep can retry a storage abort that failed.
|
|
181
|
+
*/
|
|
182
|
+
export class ChunkedUploads {
|
|
183
|
+
private readonly sql: SQL;
|
|
184
|
+
private readonly dialect: DatabaseProviderName;
|
|
185
|
+
private readonly config: UploadConfig;
|
|
186
|
+
private readonly storage: () => ObjectStore;
|
|
187
|
+
private readonly logger?: ChunkedUploadLogger;
|
|
188
|
+
private readonly now: () => number;
|
|
189
|
+
private sweepTimer?: ReturnType<typeof setTimeout>;
|
|
190
|
+
private sweeping?: Promise<void>;
|
|
191
|
+
private closed = false;
|
|
192
|
+
|
|
193
|
+
constructor(options: ChunkedUploadsOptions) {
|
|
194
|
+
this.sql = options.sql;
|
|
195
|
+
this.dialect = options.dialect;
|
|
196
|
+
this.config = options.config;
|
|
197
|
+
this.storage = options.storage;
|
|
198
|
+
this.logger = options.logger;
|
|
199
|
+
this.now = options.now ?? Date.now;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
get settings(): UploadConfig {
|
|
203
|
+
return this.config;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async create(owner: string | undefined, input: NewUpload): Promise<CreatedUpload> {
|
|
207
|
+
if (!Number.isSafeInteger(input.size) || input.size < 1) {
|
|
208
|
+
throw new AppException("UPLOAD_SIZE_INVALID", "The upload size must be a positive whole number of bytes", 400);
|
|
209
|
+
}
|
|
210
|
+
if (input.size > this.config.maxBytes) {
|
|
211
|
+
throw new AppException(
|
|
212
|
+
"UPLOAD_TOO_LARGE",
|
|
213
|
+
`The upload exceeds the ${this.config.maxBytes}-byte limit`,
|
|
214
|
+
413,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const layout = partLayout(input.size, this.config.partBytes);
|
|
218
|
+
const id = crypto.randomUUID();
|
|
219
|
+
const key = uploadKey(owner, input.filename, id);
|
|
220
|
+
const storage = this.storage();
|
|
221
|
+
const { uploadId } = await storage.createMultipartUpload(
|
|
222
|
+
key,
|
|
223
|
+
input.contentType ? { contentType: input.contentType } : {},
|
|
224
|
+
);
|
|
225
|
+
const now = this.now();
|
|
226
|
+
const expiresAt = iso(now + this.config.sessionTtlSeconds * 1000);
|
|
227
|
+
try {
|
|
228
|
+
await this.sql`
|
|
229
|
+
INSERT INTO "file_upload_sessions" (
|
|
230
|
+
"id", "ownerId", "objectKey", "multipartUploadId", "filename", "contentType",
|
|
231
|
+
"size", "partSize", "partCount", "status", "expiresAt", "createdAt", "updatedAt"
|
|
232
|
+
) VALUES (
|
|
233
|
+
${id}, ${owner ?? null}, ${key}, ${uploadId}, ${input.filename}, ${input.contentType ?? null},
|
|
234
|
+
${input.size}, ${layout.partSize}, ${layout.partCount}, 'open', ${expiresAt}, ${iso(now)}, ${iso(now)}
|
|
235
|
+
)
|
|
236
|
+
`;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await storage.abortMultipartUpload(key, uploadId).catch(() => undefined);
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
return { id, partSize: layout.partSize, partCount: layout.partCount, expiresAt };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async status(owner: string | undefined, id: string): Promise<UploadView> {
|
|
245
|
+
const upload = await this.load(owner, id);
|
|
246
|
+
const parts = await this.parts(upload.id);
|
|
247
|
+
return {
|
|
248
|
+
id: upload.id,
|
|
249
|
+
status: upload.status,
|
|
250
|
+
filename: upload.filename,
|
|
251
|
+
contentType: upload.contentType,
|
|
252
|
+
size: upload.size,
|
|
253
|
+
partSize: upload.partSize,
|
|
254
|
+
partCount: upload.partCount,
|
|
255
|
+
receivedParts: parts.map((part) => part.partNumber),
|
|
256
|
+
expiresAt: iso(upload.expiresAt),
|
|
257
|
+
...(upload.status === "completed" ? { key: upload.objectKey } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Streams one part to storage. `contentLength` must equal the part's expected
|
|
263
|
+
* size; sending a part again replaces it.
|
|
264
|
+
*/
|
|
265
|
+
async putPart(
|
|
266
|
+
owner: string | undefined,
|
|
267
|
+
id: string,
|
|
268
|
+
partNumber: number,
|
|
269
|
+
contentLength: number | null,
|
|
270
|
+
body: ReadableStream<Uint8Array> | null,
|
|
271
|
+
): Promise<StoredPart> {
|
|
272
|
+
const upload = await this.load(owner, id);
|
|
273
|
+
const uploadId = this.writable(upload);
|
|
274
|
+
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > upload.partCount) {
|
|
275
|
+
throw new AppException(
|
|
276
|
+
"UPLOAD_PART_OUT_OF_RANGE",
|
|
277
|
+
`Part numbers for this upload run from 1 to ${upload.partCount}`,
|
|
278
|
+
400,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const expected = expectedPartSize(upload.size, upload, partNumber);
|
|
282
|
+
if (contentLength === null) {
|
|
283
|
+
throw new AppException("UPLOAD_LENGTH_REQUIRED", "Send the part with a Content-Length header", 411);
|
|
284
|
+
}
|
|
285
|
+
const mismatch = new AppException(
|
|
286
|
+
"UPLOAD_PART_SIZE_MISMATCH",
|
|
287
|
+
`Part ${partNumber} must be exactly ${expected} bytes`,
|
|
288
|
+
400,
|
|
289
|
+
);
|
|
290
|
+
if (contentLength !== expected || body === null) throw mismatch;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const stored = await this.storage().uploadPart(
|
|
294
|
+
upload.objectKey,
|
|
295
|
+
uploadId,
|
|
296
|
+
partNumber,
|
|
297
|
+
exactLength(body, expected),
|
|
298
|
+
);
|
|
299
|
+
if (stored.size !== expected) throw mismatch;
|
|
300
|
+
const now = this.now();
|
|
301
|
+
const recorded = await this.sql.begin(async (tx) => {
|
|
302
|
+
// The session row is updated first: on PostgreSQL its row lock orders
|
|
303
|
+
// this part against a concurrent complete, which claims the same row.
|
|
304
|
+
const touched = await tx<Array<{ id: string }>>`
|
|
305
|
+
UPDATE "file_upload_sessions"
|
|
306
|
+
SET "expiresAt" = ${iso(now + this.config.sessionTtlSeconds * 1000)}, "updatedAt" = ${iso(now)}
|
|
307
|
+
WHERE "id" = ${upload.id} AND "status" = 'open' AND "expiresAt" > ${iso(now)}
|
|
308
|
+
RETURNING "id"
|
|
309
|
+
`;
|
|
310
|
+
if (touched.length === 0) return false;
|
|
311
|
+
await tx`
|
|
312
|
+
INSERT INTO "file_upload_parts" ("sessionId", "partNumber", "etag", "size", "updatedAt")
|
|
313
|
+
VALUES (${upload.id}, ${partNumber}, ${stored.etag}, ${stored.size}, ${iso(now)})
|
|
314
|
+
ON CONFLICT ("sessionId", "partNumber") DO UPDATE
|
|
315
|
+
SET "etag" = excluded."etag", "size" = excluded."size", "updatedAt" = excluded."updatedAt"
|
|
316
|
+
`;
|
|
317
|
+
return true;
|
|
318
|
+
});
|
|
319
|
+
if (!recorded) {
|
|
320
|
+
this.writable(await this.load(owner, id));
|
|
321
|
+
throw new AppException("UPLOAD_CONFLICT", "The upload changed while the part was stored", 409);
|
|
322
|
+
}
|
|
323
|
+
return { partNumber, size: expected };
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error instanceof PartLengthError) throw mismatch;
|
|
326
|
+
if (error instanceof MultipartUploadError && error.code === "UPLOAD_NOT_FOUND") {
|
|
327
|
+
throw await this.lost(owner, upload);
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Joins the parts into the object. Exactly one request wins the open ->
|
|
335
|
+
* completing transition; a repeated call after success returns the same result.
|
|
336
|
+
*/
|
|
337
|
+
async complete(owner: string | undefined, id: string): Promise<CompletedUpload> {
|
|
338
|
+
const upload = await this.load(owner, id);
|
|
339
|
+
if (upload.status === "completed") return this.completedResult(upload);
|
|
340
|
+
this.writable(upload);
|
|
341
|
+
this.assertAllParts(upload, await this.parts(upload.id));
|
|
342
|
+
|
|
343
|
+
const now = this.now();
|
|
344
|
+
const claimed = await this.sql<Array<{ id: string }>>`
|
|
345
|
+
UPDATE "file_upload_sessions"
|
|
346
|
+
SET "status" = 'completing', "expiresAt" = ${iso(now + COMPLETE_LEASE_MS)}, "updatedAt" = ${iso(now)}
|
|
347
|
+
WHERE "id" = ${upload.id} AND "status" = 'open' AND "expiresAt" > ${iso(now)}
|
|
348
|
+
RETURNING "id"
|
|
349
|
+
`;
|
|
350
|
+
if (claimed.length === 0) {
|
|
351
|
+
const current = await this.load(owner, id);
|
|
352
|
+
if (current.status === "completed") return this.completedResult(current);
|
|
353
|
+
this.writable(current);
|
|
354
|
+
throw new AppException("UPLOAD_COMPLETING", "The upload is being completed", 409);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// A part is recorded only while the upload is open, so this list is final.
|
|
358
|
+
const parts = await this.parts(upload.id);
|
|
359
|
+
try {
|
|
360
|
+
this.assertAllParts(upload, parts);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
await this.reopen(upload.id);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
const info = await this.assemble(upload, parts);
|
|
366
|
+
const finished = this.now();
|
|
367
|
+
await this.sql`
|
|
368
|
+
UPDATE "file_upload_sessions"
|
|
369
|
+
SET "status" = 'completed', "multipartUploadId" = NULL,
|
|
370
|
+
"expiresAt" = ${iso(finished + this.config.sessionTtlSeconds * 1000)}, "updatedAt" = ${iso(finished)}
|
|
371
|
+
WHERE "id" = ${upload.id} AND "status" = 'completing'
|
|
372
|
+
`;
|
|
373
|
+
return this.completedResult({ ...upload, size: info.size });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Cancels an open upload and discards its parts. Aborting an aborted upload succeeds. */
|
|
377
|
+
async abort(owner: string | undefined, id: string): Promise<void> {
|
|
378
|
+
let upload = await this.load(owner, id);
|
|
379
|
+
if (upload.status === "open") {
|
|
380
|
+
const now = this.now();
|
|
381
|
+
const claimed = await this.sql<Array<{ id: string }>>`
|
|
382
|
+
UPDATE "file_upload_sessions"
|
|
383
|
+
SET "status" = 'aborted', "expiresAt" = ${iso(now + this.config.sessionTtlSeconds * 1000)},
|
|
384
|
+
"updatedAt" = ${iso(now)}
|
|
385
|
+
WHERE "id" = ${upload.id} AND "status" = 'open'
|
|
386
|
+
RETURNING "id"
|
|
387
|
+
`;
|
|
388
|
+
upload = claimed.length > 0 ? { ...upload, status: "aborted" } : await this.load(owner, id);
|
|
389
|
+
}
|
|
390
|
+
if (upload.status === "completed") {
|
|
391
|
+
throw new AppException("UPLOAD_COMPLETED", "The upload has already completed", 409);
|
|
392
|
+
}
|
|
393
|
+
if (upload.status !== "aborted") {
|
|
394
|
+
throw new AppException("UPLOAD_COMPLETING", "The upload is being completed", 409);
|
|
395
|
+
}
|
|
396
|
+
if (upload.multipartUploadId !== null) {
|
|
397
|
+
await this.release(upload.id, upload.objectKey, upload.multipartUploadId);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Aborts expired uploads, retries storage aborts that failed, settles uploads
|
|
403
|
+
* a stopped replica left completing, and deletes finished records once their
|
|
404
|
+
* retention ends. Each row is claimed with a conditional UPDATE first, so
|
|
405
|
+
* replicas sweeping at the same time never handle the same upload.
|
|
406
|
+
*/
|
|
407
|
+
async sweep(): Promise<SweepResult> {
|
|
408
|
+
const result: SweepResult = { expired: 0, released: 0, recovered: 0, deleted: 0 };
|
|
409
|
+
const now = this.now();
|
|
410
|
+
const retainUntil = iso(now + this.config.sessionTtlSeconds * 1000);
|
|
411
|
+
const retryBefore = iso(now - Math.max(this.config.sweepIntervalSeconds, 60) * 1000);
|
|
412
|
+
const sql = this.sql;
|
|
413
|
+
const skipLocked = this.dialect === "postgres" ? sql`FOR UPDATE SKIP LOCKED` : sql``;
|
|
414
|
+
|
|
415
|
+
for (let batch = 0; batch < SWEEP_MAX_BATCHES && !this.closed; batch++) {
|
|
416
|
+
const rows = await sql<ClaimedRow[]>`
|
|
417
|
+
UPDATE "file_upload_sessions"
|
|
418
|
+
SET "status" = 'aborted', "expiresAt" = ${retainUntil}, "updatedAt" = ${iso(now)}
|
|
419
|
+
WHERE "id" IN (
|
|
420
|
+
SELECT "id" FROM "file_upload_sessions"
|
|
421
|
+
WHERE "status" = 'open' AND "expiresAt" < ${iso(now)}
|
|
422
|
+
ORDER BY "expiresAt" LIMIT ${SWEEP_BATCH} ${skipLocked}
|
|
423
|
+
) AND "status" = 'open' AND "expiresAt" < ${iso(now)}
|
|
424
|
+
RETURNING "id", "objectKey", "multipartUploadId", "size"
|
|
425
|
+
`;
|
|
426
|
+
for (const row of rows) {
|
|
427
|
+
if (row.multipartUploadId !== null) await this.release(row.id, row.objectKey, row.multipartUploadId);
|
|
428
|
+
}
|
|
429
|
+
result.expired += rows.length;
|
|
430
|
+
if (rows.length < SWEEP_BATCH) break;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
for (let batch = 0; batch < SWEEP_MAX_BATCHES && !this.closed; batch++) {
|
|
434
|
+
const rows = await sql<ClaimedRow[]>`
|
|
435
|
+
UPDATE "file_upload_sessions" SET "updatedAt" = ${iso(now)}
|
|
436
|
+
WHERE "id" IN (
|
|
437
|
+
SELECT "id" FROM "file_upload_sessions"
|
|
438
|
+
WHERE "status" = 'aborted' AND "multipartUploadId" IS NOT NULL AND "updatedAt" < ${retryBefore}
|
|
439
|
+
ORDER BY "updatedAt" LIMIT ${SWEEP_BATCH} ${skipLocked}
|
|
440
|
+
) AND "status" = 'aborted' AND "multipartUploadId" IS NOT NULL AND "updatedAt" < ${retryBefore}
|
|
441
|
+
RETURNING "id", "objectKey", "multipartUploadId", "size"
|
|
442
|
+
`;
|
|
443
|
+
for (const row of rows) {
|
|
444
|
+
if (row.multipartUploadId !== null && await this.release(row.id, row.objectKey, row.multipartUploadId)) {
|
|
445
|
+
result.released += 1;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (rows.length < SWEEP_BATCH) break;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
for (let batch = 0; batch < SWEEP_MAX_BATCHES && !this.closed; batch++) {
|
|
452
|
+
const rows = await sql<ClaimedRow[]>`
|
|
453
|
+
UPDATE "file_upload_sessions"
|
|
454
|
+
SET "expiresAt" = ${iso(now + COMPLETE_LEASE_MS)}, "updatedAt" = ${iso(now)}
|
|
455
|
+
WHERE "id" IN (
|
|
456
|
+
SELECT "id" FROM "file_upload_sessions"
|
|
457
|
+
WHERE "status" = 'completing' AND "expiresAt" < ${iso(now)}
|
|
458
|
+
ORDER BY "expiresAt" LIMIT ${SWEEP_BATCH} ${skipLocked}
|
|
459
|
+
) AND "status" = 'completing' AND "expiresAt" < ${iso(now)}
|
|
460
|
+
RETURNING "id", "objectKey", "multipartUploadId", "size"
|
|
461
|
+
`;
|
|
462
|
+
for (const row of rows) {
|
|
463
|
+
await this.settle(row, retainUntil);
|
|
464
|
+
result.recovered += 1;
|
|
465
|
+
}
|
|
466
|
+
if (rows.length < SWEEP_BATCH) break;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (let batch = 0; batch < SWEEP_MAX_BATCHES && !this.closed; batch++) {
|
|
470
|
+
// Parts are removed with their session by the foreign key.
|
|
471
|
+
const rows = await sql<Array<{ id: string }>>`
|
|
472
|
+
DELETE FROM "file_upload_sessions" WHERE "id" IN (
|
|
473
|
+
SELECT "id" FROM "file_upload_sessions"
|
|
474
|
+
WHERE "status" IN ('completed', 'aborted') AND "multipartUploadId" IS NULL AND "expiresAt" < ${iso(now)}
|
|
475
|
+
ORDER BY "expiresAt" LIMIT ${SWEEP_BATCH}
|
|
476
|
+
)
|
|
477
|
+
RETURNING "id"
|
|
478
|
+
`;
|
|
479
|
+
result.deleted += rows.length;
|
|
480
|
+
if (rows.length < SWEEP_BATCH) break;
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Starts the periodic sweep unless FILE_UPLOAD_SWEEP_INTERVAL is 0. */
|
|
486
|
+
start(): void {
|
|
487
|
+
this.closed = false;
|
|
488
|
+
if (this.config.sweepIntervalSeconds > 0 && !this.sweepTimer && !this.sweeping) {
|
|
489
|
+
this.scheduleSweep(this.config.sweepIntervalSeconds * 1000);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Stops the sweep and waits for a running one to finish its current batch. */
|
|
494
|
+
async close(): Promise<void> {
|
|
495
|
+
this.closed = true;
|
|
496
|
+
if (this.sweepTimer) clearTimeout(this.sweepTimer);
|
|
497
|
+
this.sweepTimer = undefined;
|
|
498
|
+
await this.sweeping;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private scheduleSweep(delayMs: number): void {
|
|
502
|
+
this.sweepTimer = setTimeout(() => {
|
|
503
|
+
this.sweepTimer = undefined;
|
|
504
|
+
this.sweeping = this.sweep()
|
|
505
|
+
.then(() => undefined, (error: unknown) => {
|
|
506
|
+
this.logger?.warn({ error: message(error) }, "Upload sweep failed");
|
|
507
|
+
})
|
|
508
|
+
.finally(() => {
|
|
509
|
+
this.sweeping = undefined;
|
|
510
|
+
if (!this.closed) this.scheduleSweep(this.config.sweepIntervalSeconds * 1000);
|
|
511
|
+
});
|
|
512
|
+
}, delayMs);
|
|
513
|
+
this.sweepTimer.unref?.();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Owners only: another caller's upload is reported as missing, not forbidden. */
|
|
517
|
+
private async load(owner: string | undefined, id: string): Promise<Session> {
|
|
518
|
+
if (!UUID.test(id)) throw notFound();
|
|
519
|
+
const rows = await this.sql<SessionRow[]>`
|
|
520
|
+
SELECT "id", "ownerId", "objectKey", "multipartUploadId", "filename", "contentType",
|
|
521
|
+
"size", "partSize", "partCount", "status", "expiresAt"
|
|
522
|
+
FROM "file_upload_sessions" WHERE "id" = ${id}
|
|
523
|
+
`;
|
|
524
|
+
const row = rows[0];
|
|
525
|
+
if (!row || (row.ownerId ?? undefined) !== owner) throw notFound();
|
|
526
|
+
return session(row);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private async parts(id: string): Promise<CompletedPart[]> {
|
|
530
|
+
const rows = await this.sql<Array<{ partNumber: number | string; etag: string }>>`
|
|
531
|
+
SELECT "partNumber", "etag" FROM "file_upload_parts"
|
|
532
|
+
WHERE "sessionId" = ${id} ORDER BY "partNumber"
|
|
533
|
+
`;
|
|
534
|
+
return rows.map((row) => ({ partNumber: Number(row.partNumber), etag: row.etag }));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Throws unless the upload accepts parts; returns its storage upload id. */
|
|
538
|
+
private writable(upload: Session): string {
|
|
539
|
+
if (upload.status === "completed") {
|
|
540
|
+
throw new AppException("UPLOAD_COMPLETED", "The upload has already completed", 409);
|
|
541
|
+
}
|
|
542
|
+
if (upload.status === "completing") {
|
|
543
|
+
throw new AppException("UPLOAD_COMPLETING", "The upload is being completed", 409);
|
|
544
|
+
}
|
|
545
|
+
if (upload.status === "aborted" || upload.multipartUploadId === null) {
|
|
546
|
+
throw new AppException("UPLOAD_ABORTED", "The upload was cancelled or expired; start a new one", 410);
|
|
547
|
+
}
|
|
548
|
+
if (upload.expiresAt <= this.now()) {
|
|
549
|
+
throw new AppException("UPLOAD_EXPIRED", "The upload expired; start a new one", 410);
|
|
550
|
+
}
|
|
551
|
+
return upload.multipartUploadId;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private assertAllParts(upload: Session, parts: CompletedPart[]): void {
|
|
555
|
+
const received = new Set(parts.map((part) => part.partNumber));
|
|
556
|
+
const missing: number[] = [];
|
|
557
|
+
for (let number = 1; number <= upload.partCount; number++) {
|
|
558
|
+
if (!received.has(number)) missing.push(number);
|
|
559
|
+
}
|
|
560
|
+
if (missing.length > 0) {
|
|
561
|
+
throw new AppException("UPLOAD_INCOMPLETE", `Parts ${listed(missing)} have not been uploaded`, 409);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Completes the storage upload for a session this request holds in "completing". */
|
|
566
|
+
private async assemble(upload: Session, parts: CompletedPart[]): Promise<ObjectInfo> {
|
|
567
|
+
const storage = this.storage();
|
|
568
|
+
try {
|
|
569
|
+
if (upload.multipartUploadId === null) throw new MultipartUploadError("UPLOAD_NOT_FOUND", "No storage upload");
|
|
570
|
+
return await storage.completeMultipartUpload(upload.objectKey, upload.multipartUploadId, parts);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (error instanceof MultipartUploadError && error.code === "UPLOAD_NOT_FOUND") {
|
|
573
|
+
// A complete whose response was lost has already put the object in place.
|
|
574
|
+
const existing = await storage.stat(upload.objectKey);
|
|
575
|
+
if (existing && existing.size === upload.size) return existing;
|
|
576
|
+
await this.markAborted(upload.id, "completing");
|
|
577
|
+
throw notFound("The upload no longer exists in storage; start a new one");
|
|
578
|
+
}
|
|
579
|
+
await this.reopen(upload.id);
|
|
580
|
+
if (error instanceof MultipartUploadError) {
|
|
581
|
+
throw error.code === "INVALID_PART"
|
|
582
|
+
? new AppException("UPLOAD_INVALID_PART", "A stored part changed or is missing; upload the parts again", 409)
|
|
583
|
+
: new AppException("UPLOAD_PART_TOO_SMALL", error.message, 409);
|
|
584
|
+
}
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private async completedResult(upload: Session): Promise<CompletedUpload> {
|
|
590
|
+
return {
|
|
591
|
+
id: upload.id,
|
|
592
|
+
status: "completed",
|
|
593
|
+
key: upload.objectKey,
|
|
594
|
+
size: upload.size,
|
|
595
|
+
contentType: upload.contentType,
|
|
596
|
+
url: await this.storage().getDownloadUrl(upload.objectKey),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private async reopen(id: string): Promise<void> {
|
|
601
|
+
const now = this.now();
|
|
602
|
+
await this.sql`
|
|
603
|
+
UPDATE "file_upload_sessions"
|
|
604
|
+
SET "status" = 'open', "expiresAt" = ${iso(now + this.config.sessionTtlSeconds * 1000)}, "updatedAt" = ${iso(now)}
|
|
605
|
+
WHERE "id" = ${id} AND "status" = 'completing'
|
|
606
|
+
`;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
private async markAborted(id: string, from: UploadStatus): Promise<boolean> {
|
|
610
|
+
const now = this.now();
|
|
611
|
+
const rows = await this.sql<Array<{ id: string }>>`
|
|
612
|
+
UPDATE "file_upload_sessions"
|
|
613
|
+
SET "status" = 'aborted', "multipartUploadId" = NULL,
|
|
614
|
+
"expiresAt" = ${iso(now + this.config.sessionTtlSeconds * 1000)}, "updatedAt" = ${iso(now)}
|
|
615
|
+
WHERE "id" = ${id} AND "status" = ${from}
|
|
616
|
+
RETURNING "id"
|
|
617
|
+
`;
|
|
618
|
+
return rows.length > 0;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Storage no longer knows an upload the session still holds open. */
|
|
622
|
+
private async lost(owner: string | undefined, upload: Session): Promise<AppException> {
|
|
623
|
+
if (await this.markAborted(upload.id, "open")) {
|
|
624
|
+
return notFound("The upload no longer exists in storage; start a new one");
|
|
625
|
+
}
|
|
626
|
+
// Another request finished or cancelled it meanwhile; report that state.
|
|
627
|
+
try {
|
|
628
|
+
this.writable(await this.load(owner, upload.id));
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (error instanceof AppException) return error;
|
|
631
|
+
throw error;
|
|
632
|
+
}
|
|
633
|
+
return notFound("The upload no longer exists in storage; start a new one");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Aborts the storage upload and forgets its id. A failure is retried by the sweep. */
|
|
637
|
+
private async release(id: string, key: string, uploadId: string): Promise<boolean> {
|
|
638
|
+
try {
|
|
639
|
+
await this.storage().abortMultipartUpload(key, uploadId);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
this.logger?.warn({ uploadId: id, error: message(error) }, "Abort of a storage multipart upload failed");
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
await this.sql`
|
|
645
|
+
UPDATE "file_upload_sessions" SET "multipartUploadId" = NULL
|
|
646
|
+
WHERE "id" = ${id} AND "status" = 'aborted'
|
|
647
|
+
`;
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Settles an upload whose completing replica stopped before recording the result. */
|
|
652
|
+
private async settle(row: ClaimedRow, retainUntil: string): Promise<void> {
|
|
653
|
+
const existing = await this.storage().stat(row.objectKey).catch(() => null);
|
|
654
|
+
if (existing && existing.size === Number(row.size)) {
|
|
655
|
+
await this.sql`
|
|
656
|
+
UPDATE "file_upload_sessions"
|
|
657
|
+
SET "status" = 'completed', "multipartUploadId" = NULL, "expiresAt" = ${retainUntil},
|
|
658
|
+
"updatedAt" = ${iso(this.now())}
|
|
659
|
+
WHERE "id" = ${row.id} AND "status" = 'completing'
|
|
660
|
+
`;
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const rows = await this.sql<Array<{ id: string }>>`
|
|
664
|
+
UPDATE "file_upload_sessions"
|
|
665
|
+
SET "status" = 'aborted', "expiresAt" = ${retainUntil}, "updatedAt" = ${iso(this.now())}
|
|
666
|
+
WHERE "id" = ${row.id} AND "status" = 'completing'
|
|
667
|
+
RETURNING "id"
|
|
668
|
+
`;
|
|
669
|
+
if (rows.length > 0 && row.multipartUploadId !== null) {
|
|
670
|
+
await this.release(row.id, row.objectKey, row.multipartUploadId);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|