was-teaching-server 0.9.1 → 0.10.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/CHANGELOG.md +50 -1
- package/dist/backends/filesystem.d.ts +161 -2
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +491 -60
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +166 -0
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +625 -29
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +25 -0
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -1
- package/dist/lib/exportManifest.d.ts +9 -0
- package/dist/lib/exportManifest.d.ts.map +1 -1
- package/dist/lib/exportManifest.js +9 -0
- package/dist/lib/exportManifest.js.map +1 -1
- package/dist/lib/importTar.d.ts +18 -2
- package/dist/lib/importTar.d.ts.map +1 -1
- package/dist/lib/importTar.js +77 -4
- package/dist/lib/importTar.js.map +1 -1
- package/dist/lib/paths.d.ts +33 -0
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +28 -0
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/resourceFileName.d.ts +43 -0
- package/dist/lib/resourceFileName.d.ts.map +1 -1
- package/dist/lib/resourceFileName.js +63 -0
- package/dist/lib/resourceFileName.js.map +1 -1
- package/dist/requests/ChunkRequest.d.ts +104 -0
- package/dist/requests/ChunkRequest.d.ts.map +1 -0
- package/dist/requests/ChunkRequest.js +396 -0
- package/dist/requests/ChunkRequest.js.map +1 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +21 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +81 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/backends/filesystem.ts +638 -57
- package/src/backends/postgres.ts +867 -21
- package/src/backends/postgresSchema.ts +69 -57
- package/src/errors.ts +19 -0
- package/src/lib/importTar.ts +107 -4
- package/src/lib/paths.ts +48 -0
- package/src/lib/resourceFileName.ts +69 -0
- package/src/requests/ChunkRequest.ts +478 -0
- package/src/routes.ts +46 -0
- package/src/types.ts +78 -1
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request handlers for chunk operations on a chunked Resource (the
|
|
3
|
+
* `chunked-streams` feature): store, fetch, head, delete a single chunk at
|
|
4
|
+
* `/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex`, and list a
|
|
5
|
+
* Resource's chunks at the `chunks/` container. A chunk body is opaque bytes +
|
|
6
|
+
* content-type -- the server stores it exactly like a binary Resource
|
|
7
|
+
* representation and never parses it (any encryption framing is client-side),
|
|
8
|
+
* so the Collection-level encryption conformance check deliberately does NOT
|
|
9
|
+
* apply here: an encrypted stream's chunks are ciphertext fragments, not
|
|
10
|
+
* envelope documents.
|
|
11
|
+
*/
|
|
12
|
+
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
13
|
+
import { fetchSpaceAndAuthorize, fetchSpaceAndVerify } from './spaceContext.js'
|
|
14
|
+
import { getCollectionOrThrow } from './collectionContext.js'
|
|
15
|
+
import { resolveResourceInput } from './resourceInput.js'
|
|
16
|
+
import { resolveBackend } from '../lib/backendRegistry.js'
|
|
17
|
+
import { assertValidIds } from '../lib/validateId.js'
|
|
18
|
+
import { parseChunkIndexSegment } from '../lib/resourceFileName.js'
|
|
19
|
+
import { chunkPath, chunksContainerPath } from '../lib/paths.js'
|
|
20
|
+
import { formatEtag, parseWritePreconditions } from '../lib/etag.js'
|
|
21
|
+
import {
|
|
22
|
+
InvalidChunkIndexError,
|
|
23
|
+
ResourceNotFoundError,
|
|
24
|
+
rethrowOrWrapStorageError
|
|
25
|
+
} from '../errors.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses and validates the `:chunkIndex` path param -- the canonical decimal
|
|
29
|
+
* spelling of an integer in `[0, MAX_CHUNK_INDEX]` (see
|
|
30
|
+
* `parseChunkIndexSegment`) -- throwing `InvalidChunkIndexError` (400)
|
|
31
|
+
* otherwise. Runs before any storage access, like `assertValidIds`.
|
|
32
|
+
* @param raw {string} the `:chunkIndex` path param
|
|
33
|
+
* @param options {object}
|
|
34
|
+
* @param options.requestName {string} request name used in the error title
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
function parseChunkIndex(
|
|
38
|
+
raw: string,
|
|
39
|
+
{ requestName }: { requestName: string }
|
|
40
|
+
): number {
|
|
41
|
+
const chunkIndex = parseChunkIndexSegment(raw)
|
|
42
|
+
if (chunkIndex === undefined) {
|
|
43
|
+
throw new InvalidChunkIndexError({ requestName })
|
|
44
|
+
}
|
|
45
|
+
return chunkIndex
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The URL params every chunk-member route carries. */
|
|
49
|
+
interface ChunkParams {
|
|
50
|
+
spaceId: string
|
|
51
|
+
collectionId: string
|
|
52
|
+
resourceId: string
|
|
53
|
+
chunkIndex: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class ChunkRequest {
|
|
57
|
+
/**
|
|
58
|
+
* PUT /space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex
|
|
59
|
+
* Request handler for "Put Chunk": stores chunk `chunkIndex` of the Resource
|
|
60
|
+
* (raw bytes body, upsert). The parent Resource MUST already exist (404
|
|
61
|
+
* otherwise -- chunks cannot be orphaned). Authorization is capability-only
|
|
62
|
+
* against the chunk's own full URL, the same exact-match zcap target rule as
|
|
63
|
+
* every other route. Supports `If-Match` / `If-None-Match` on the chunk's
|
|
64
|
+
* own ETag, and the backend's `maxUploadBytes` cap (413). Returns 204 with
|
|
65
|
+
* the chunk's new ETag.
|
|
66
|
+
*
|
|
67
|
+
* @param request {import('fastify').FastifyRequest}
|
|
68
|
+
* @param reply {import('fastify').FastifyReply}
|
|
69
|
+
* @returns {Promise<FastifyReply>}
|
|
70
|
+
*/
|
|
71
|
+
static async put(
|
|
72
|
+
request: FastifyRequest<{ Params: ChunkParams }>,
|
|
73
|
+
reply: FastifyReply
|
|
74
|
+
): Promise<FastifyReply> {
|
|
75
|
+
const {
|
|
76
|
+
params: { spaceId, collectionId, resourceId }
|
|
77
|
+
} = request
|
|
78
|
+
const { storage } = request.server
|
|
79
|
+
const requestName = 'Put Chunk'
|
|
80
|
+
|
|
81
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
82
|
+
assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
|
|
83
|
+
const chunkIndex = parseChunkIndex(request.params.chunkIndex, {
|
|
84
|
+
requestName
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// Verify (capability-only): writing a chunk requires a valid capability
|
|
88
|
+
// invocation against the chunk's own URL; no access-control-policy fallback.
|
|
89
|
+
await fetchSpaceAndVerify({
|
|
90
|
+
request,
|
|
91
|
+
spaceId,
|
|
92
|
+
targetPath: chunkPath({ spaceId, collectionId, resourceId, chunkIndex }),
|
|
93
|
+
requestName
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// zCap checks out, continue
|
|
97
|
+
|
|
98
|
+
// Fetch collection by id
|
|
99
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
100
|
+
storage,
|
|
101
|
+
spaceId,
|
|
102
|
+
collectionId,
|
|
103
|
+
requestName
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Route chunk bytes to the Collection's selected (data-plane) backend.
|
|
107
|
+
const dataBackend = await resolveBackend({
|
|
108
|
+
request,
|
|
109
|
+
spaceId,
|
|
110
|
+
collectionId,
|
|
111
|
+
collectionDescription
|
|
112
|
+
})
|
|
113
|
+
const input = await resolveResourceInput(request, dataBackend)
|
|
114
|
+
// Surface any `If-Match` / `If-None-Match` write precondition to the
|
|
115
|
+
// storage layer, which evaluates it against the chunk's own version
|
|
116
|
+
// atomically with the write (412 `precondition-failed` on a mismatch).
|
|
117
|
+
let written: { version: number }
|
|
118
|
+
try {
|
|
119
|
+
written = await dataBackend.writeChunk({
|
|
120
|
+
spaceId,
|
|
121
|
+
collectionId,
|
|
122
|
+
resourceId,
|
|
123
|
+
chunkIndex,
|
|
124
|
+
input,
|
|
125
|
+
...parseWritePreconditions(request.headers)
|
|
126
|
+
})
|
|
127
|
+
} catch (err) {
|
|
128
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
129
|
+
}
|
|
130
|
+
// Return the new ETag so a client can chain a subsequent conditional write.
|
|
131
|
+
return reply.status(204).header('etag', formatEtag(written.version)).send()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* GET /space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex
|
|
136
|
+
* Request handler for "Get Chunk": streams the chunk's stored bytes.
|
|
137
|
+
* Authorization is capability-or-policy (the same read decision as Get
|
|
138
|
+
* Resource, resolved at the Resource's policy level -- a chunk reveals a
|
|
139
|
+
* fragment of the same content).
|
|
140
|
+
*
|
|
141
|
+
* @param request {import('fastify').FastifyRequest}
|
|
142
|
+
* @param reply {import('fastify').FastifyReply}
|
|
143
|
+
* @returns {Promise<FastifyReply>}
|
|
144
|
+
*/
|
|
145
|
+
static async get(
|
|
146
|
+
request: FastifyRequest<{ Params: ChunkParams }>,
|
|
147
|
+
reply: FastifyReply
|
|
148
|
+
): Promise<FastifyReply> {
|
|
149
|
+
const {
|
|
150
|
+
params: { spaceId, collectionId, resourceId }
|
|
151
|
+
} = request
|
|
152
|
+
const { storage } = request.server
|
|
153
|
+
const requestName = 'Get Chunk'
|
|
154
|
+
|
|
155
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
156
|
+
assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
|
|
157
|
+
const chunkIndex = parseChunkIndex(request.params.chunkIndex, {
|
|
158
|
+
requestName
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Authorize (capability-or-policy) against the chunk's own URL; the policy
|
|
162
|
+
// level resolves at the parent Resource, as for Get Resource.
|
|
163
|
+
await fetchSpaceAndAuthorize({
|
|
164
|
+
request,
|
|
165
|
+
spaceId,
|
|
166
|
+
collectionId,
|
|
167
|
+
resourceId,
|
|
168
|
+
targetPath: chunkPath({ spaceId, collectionId, resourceId, chunkIndex }),
|
|
169
|
+
requestName
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// authorized, continue
|
|
173
|
+
|
|
174
|
+
// Fetch collection by id
|
|
175
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
176
|
+
storage,
|
|
177
|
+
spaceId,
|
|
178
|
+
collectionId,
|
|
179
|
+
requestName
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Read the bytes from the Collection's selected (data-plane) backend.
|
|
183
|
+
const dataBackend = await resolveBackend({
|
|
184
|
+
request,
|
|
185
|
+
spaceId,
|
|
186
|
+
collectionId,
|
|
187
|
+
collectionDescription
|
|
188
|
+
})
|
|
189
|
+
let result
|
|
190
|
+
try {
|
|
191
|
+
// The parent Resource must exist (and not be a tombstone) for any of
|
|
192
|
+
// its chunks to be readable: an orphan chunk left behind by
|
|
193
|
+
// out-of-band state 404s here exactly like the Resource route and the
|
|
194
|
+
// chunk listing do -- checked via its metadata, never its byte stream.
|
|
195
|
+
const parent = await dataBackend.getResourceMetadata({
|
|
196
|
+
spaceId,
|
|
197
|
+
collectionId,
|
|
198
|
+
resourceId
|
|
199
|
+
})
|
|
200
|
+
if (!parent) {
|
|
201
|
+
throw new ResourceNotFoundError({ requestName })
|
|
202
|
+
}
|
|
203
|
+
result = await dataBackend.getChunk({
|
|
204
|
+
spaceId,
|
|
205
|
+
collectionId,
|
|
206
|
+
resourceId,
|
|
207
|
+
chunkIndex
|
|
208
|
+
})
|
|
209
|
+
} catch (err) {
|
|
210
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const getReply = reply.status(200).type(result.storedResourceType)
|
|
214
|
+
if (result.version !== undefined) {
|
|
215
|
+
getReply.header('etag', formatEtag(result.version))
|
|
216
|
+
}
|
|
217
|
+
return getReply.send(result.resourceStream)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* HEAD /space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex
|
|
222
|
+
* Request handler for "Head Chunk": the same authorization as Get Chunk with
|
|
223
|
+
* no response body; `Content-Type` / `Content-Length` come from the chunk's
|
|
224
|
+
* stored metadata, so the byte stream is never opened (mirrors Head
|
|
225
|
+
* Resource). Registered explicitly ahead of the GET route for the same
|
|
226
|
+
* reason Head Resource is.
|
|
227
|
+
*
|
|
228
|
+
* @param request {import('fastify').FastifyRequest}
|
|
229
|
+
* @param reply {import('fastify').FastifyReply}
|
|
230
|
+
* @returns {Promise<FastifyReply>}
|
|
231
|
+
*/
|
|
232
|
+
static async head(
|
|
233
|
+
request: FastifyRequest<{ Params: ChunkParams }>,
|
|
234
|
+
reply: FastifyReply
|
|
235
|
+
): Promise<FastifyReply> {
|
|
236
|
+
const {
|
|
237
|
+
params: { spaceId, collectionId, resourceId }
|
|
238
|
+
} = request
|
|
239
|
+
const { storage } = request.server
|
|
240
|
+
const requestName = 'Head Chunk'
|
|
241
|
+
|
|
242
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
243
|
+
assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
|
|
244
|
+
const chunkIndex = parseChunkIndex(request.params.chunkIndex, {
|
|
245
|
+
requestName
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Authorize (capability-or-policy): the same read decision as Get Chunk,
|
|
249
|
+
// against the same target (a HEAD reveals nothing a GET would not).
|
|
250
|
+
await fetchSpaceAndAuthorize({
|
|
251
|
+
request,
|
|
252
|
+
spaceId,
|
|
253
|
+
collectionId,
|
|
254
|
+
resourceId,
|
|
255
|
+
targetPath: chunkPath({ spaceId, collectionId, resourceId, chunkIndex }),
|
|
256
|
+
requestName
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
// authorized, continue
|
|
260
|
+
|
|
261
|
+
// Fetch collection by id
|
|
262
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
263
|
+
storage,
|
|
264
|
+
spaceId,
|
|
265
|
+
collectionId,
|
|
266
|
+
requestName
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Read chunk metadata from the Collection's selected (data-plane) backend.
|
|
270
|
+
const dataBackend = await resolveBackend({
|
|
271
|
+
request,
|
|
272
|
+
spaceId,
|
|
273
|
+
collectionId,
|
|
274
|
+
collectionDescription
|
|
275
|
+
})
|
|
276
|
+
let metadata
|
|
277
|
+
try {
|
|
278
|
+
// The same parent-Resource existence gate as Get Chunk: an orphan
|
|
279
|
+
// chunk's headers reveal what a GET would.
|
|
280
|
+
const parent = await dataBackend.getResourceMetadata({
|
|
281
|
+
spaceId,
|
|
282
|
+
collectionId,
|
|
283
|
+
resourceId
|
|
284
|
+
})
|
|
285
|
+
if (!parent) {
|
|
286
|
+
throw new ResourceNotFoundError({ requestName })
|
|
287
|
+
}
|
|
288
|
+
metadata = await dataBackend.getChunkMetadata({
|
|
289
|
+
spaceId,
|
|
290
|
+
collectionId,
|
|
291
|
+
resourceId,
|
|
292
|
+
chunkIndex
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
296
|
+
}
|
|
297
|
+
if (!metadata) {
|
|
298
|
+
throw new ResourceNotFoundError({ requestName })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Set the payload headers a GET would send, but send no body.
|
|
302
|
+
const headReply = reply
|
|
303
|
+
.status(200)
|
|
304
|
+
.type(metadata.contentType)
|
|
305
|
+
.header('content-length', metadata.size)
|
|
306
|
+
if (metadata.version !== undefined) {
|
|
307
|
+
headReply.header('etag', formatEtag(metadata.version))
|
|
308
|
+
}
|
|
309
|
+
return headReply.send()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* DELETE /space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex
|
|
314
|
+
* Request handler for "Delete Chunk": removes one stored chunk. Unlike
|
|
315
|
+
* Delete Resource, deleting an absent chunk is a 404 (mirroring the EDV
|
|
316
|
+
* chunk contract -- a reassembling reader must be able to distinguish "gone"
|
|
317
|
+
* from "never written"). Authorization is capability-only against the
|
|
318
|
+
* chunk's own URL. Supports `If-Match` (412 on mismatch). Returns 204.
|
|
319
|
+
*
|
|
320
|
+
* @param request {import('fastify').FastifyRequest}
|
|
321
|
+
* @param reply {import('fastify').FastifyReply}
|
|
322
|
+
* @returns {Promise<FastifyReply>}
|
|
323
|
+
*/
|
|
324
|
+
static async delete(
|
|
325
|
+
request: FastifyRequest<{ Params: ChunkParams }>,
|
|
326
|
+
reply: FastifyReply
|
|
327
|
+
): Promise<FastifyReply> {
|
|
328
|
+
const {
|
|
329
|
+
params: { spaceId, collectionId, resourceId }
|
|
330
|
+
} = request
|
|
331
|
+
const { storage } = request.server
|
|
332
|
+
const requestName = 'Delete Chunk'
|
|
333
|
+
|
|
334
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
335
|
+
assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
|
|
336
|
+
const chunkIndex = parseChunkIndex(request.params.chunkIndex, {
|
|
337
|
+
requestName
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// Verify (capability-only): deleting a chunk requires a valid capability
|
|
341
|
+
// invocation; no access-control-policy fallback.
|
|
342
|
+
await fetchSpaceAndVerify({
|
|
343
|
+
request,
|
|
344
|
+
spaceId,
|
|
345
|
+
targetPath: chunkPath({ spaceId, collectionId, resourceId, chunkIndex }),
|
|
346
|
+
requestName
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// Fetch collection by id
|
|
350
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
351
|
+
storage,
|
|
352
|
+
spaceId,
|
|
353
|
+
collectionId,
|
|
354
|
+
requestName
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// zCap checks out, continue. An `If-Match` precondition is evaluated by
|
|
358
|
+
// the storage layer atomically with the removal (412 on mismatch).
|
|
359
|
+
const { ifMatch } = parseWritePreconditions(request.headers)
|
|
360
|
+
const dataBackend = await resolveBackend({
|
|
361
|
+
request,
|
|
362
|
+
spaceId,
|
|
363
|
+
collectionId,
|
|
364
|
+
collectionDescription
|
|
365
|
+
})
|
|
366
|
+
let removed: boolean
|
|
367
|
+
try {
|
|
368
|
+
// The same parent-Resource existence gate as the read handlers: a
|
|
369
|
+
// chunk of an absent (or tombstoned) parent is a 404, not a deletable
|
|
370
|
+
// orphan.
|
|
371
|
+
const parent = await dataBackend.getResourceMetadata({
|
|
372
|
+
spaceId,
|
|
373
|
+
collectionId,
|
|
374
|
+
resourceId
|
|
375
|
+
})
|
|
376
|
+
if (!parent) {
|
|
377
|
+
throw new ResourceNotFoundError({ requestName })
|
|
378
|
+
}
|
|
379
|
+
removed = await dataBackend.deleteChunk({
|
|
380
|
+
spaceId,
|
|
381
|
+
collectionId,
|
|
382
|
+
resourceId,
|
|
383
|
+
chunkIndex,
|
|
384
|
+
ifMatch
|
|
385
|
+
})
|
|
386
|
+
} catch (err) {
|
|
387
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
388
|
+
}
|
|
389
|
+
if (!removed) {
|
|
390
|
+
throw new ResourceNotFoundError({ requestName })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return reply.status(204).send()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* GET /space/:spaceId/:collectionId/:resourceId/chunks/
|
|
398
|
+
* Request handler for "List Chunks": the discovery/reassembly listing. The
|
|
399
|
+
* server never reassembles a chunked Resource -- a reader learns the chunk
|
|
400
|
+
* set here (count + per-chunk index/size/contentType/version) and fetches
|
|
401
|
+
* `0..count-1` itself. Requires the parent Resource to exist (404
|
|
402
|
+
* otherwise). Authorization is capability-or-policy against the `chunks/`
|
|
403
|
+
* container path, resolved at the Resource's policy level.
|
|
404
|
+
*
|
|
405
|
+
* @param request {import('fastify').FastifyRequest}
|
|
406
|
+
* @param reply {import('fastify').FastifyReply}
|
|
407
|
+
* @returns {Promise<FastifyReply>}
|
|
408
|
+
*/
|
|
409
|
+
static async list(
|
|
410
|
+
request: FastifyRequest<{
|
|
411
|
+
Params: { spaceId: string; collectionId: string; resourceId: string }
|
|
412
|
+
}>,
|
|
413
|
+
reply: FastifyReply
|
|
414
|
+
): Promise<FastifyReply> {
|
|
415
|
+
const {
|
|
416
|
+
params: { spaceId, collectionId, resourceId }
|
|
417
|
+
} = request
|
|
418
|
+
const { storage } = request.server
|
|
419
|
+
const requestName = 'List Chunks'
|
|
420
|
+
|
|
421
|
+
// Reject path-traversal / non-URL-safe ids before any storage access.
|
|
422
|
+
assertValidIds({ spaceId, collectionId, resourceId }, { requestName })
|
|
423
|
+
|
|
424
|
+
// Authorize (capability-or-policy) against the `chunks/` container path;
|
|
425
|
+
// the policy level resolves at the parent Resource.
|
|
426
|
+
await fetchSpaceAndAuthorize({
|
|
427
|
+
request,
|
|
428
|
+
spaceId,
|
|
429
|
+
collectionId,
|
|
430
|
+
resourceId,
|
|
431
|
+
targetPath: chunksContainerPath({ spaceId, collectionId, resourceId }),
|
|
432
|
+
requestName
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// authorized, continue
|
|
436
|
+
|
|
437
|
+
// Fetch collection by id
|
|
438
|
+
const collectionDescription = await getCollectionOrThrow({
|
|
439
|
+
storage,
|
|
440
|
+
spaceId,
|
|
441
|
+
collectionId,
|
|
442
|
+
requestName
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
// List from the Collection's selected (data-plane) backend. The parent
|
|
446
|
+
// Resource must exist for its chunk listing to (an absent Resource has no
|
|
447
|
+
// `chunks/` container) -- checked via its metadata, never its byte stream.
|
|
448
|
+
const dataBackend = await resolveBackend({
|
|
449
|
+
request,
|
|
450
|
+
spaceId,
|
|
451
|
+
collectionId,
|
|
452
|
+
collectionDescription
|
|
453
|
+
})
|
|
454
|
+
let listing
|
|
455
|
+
try {
|
|
456
|
+
const parent = await dataBackend.getResourceMetadata({
|
|
457
|
+
spaceId,
|
|
458
|
+
collectionId,
|
|
459
|
+
resourceId
|
|
460
|
+
})
|
|
461
|
+
if (!parent) {
|
|
462
|
+
throw new ResourceNotFoundError({ requestName })
|
|
463
|
+
}
|
|
464
|
+
listing = await dataBackend.listChunks({
|
|
465
|
+
spaceId,
|
|
466
|
+
collectionId,
|
|
467
|
+
resourceId
|
|
468
|
+
})
|
|
469
|
+
} catch (err) {
|
|
470
|
+
rethrowOrWrapStorageError({ err, requestName })
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return reply
|
|
474
|
+
.status(200)
|
|
475
|
+
.type('application/json')
|
|
476
|
+
.send(JSON.stringify({ resourceId, ...listing }))
|
|
477
|
+
}
|
|
478
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { SpacesRepositoryRequest } from './requests/SpacesRepositoryRequest.js'
|
|
|
16
16
|
import { SpaceRequest } from './requests/SpaceRequest.js'
|
|
17
17
|
import { handleError } from './errors.js'
|
|
18
18
|
import { ResourceRequest } from './requests/ResourceRequest.js'
|
|
19
|
+
import { ChunkRequest } from './requests/ChunkRequest.js'
|
|
19
20
|
import { CollectionRequest } from './requests/CollectionRequest.js'
|
|
20
21
|
import { PolicyRequest } from './requests/PolicyRequest.js'
|
|
21
22
|
import { BackendRequest } from './requests/BackendRequest.js'
|
|
@@ -351,6 +352,51 @@ export async function initResourceRoutes(
|
|
|
351
352
|
'/space/:spaceId/:collectionId/:resourceId/meta',
|
|
352
353
|
ResourceRequest.putMeta
|
|
353
354
|
)
|
|
355
|
+
|
|
356
|
+
// Chunked Resource chunks (the `chunked-streams` feature). The member form
|
|
357
|
+
// (`chunks/:chunkIndex`) addresses one stored chunk; the container form
|
|
358
|
+
// (`chunks/`) is the discovery/reassembly listing. Like `meta`, the `chunks`
|
|
359
|
+
// segment sits below the Resource level, so it needs no reserved-id entry.
|
|
360
|
+
|
|
361
|
+
// Store a chunk by index
|
|
362
|
+
app.put(
|
|
363
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex/', // no trailing slash allowed
|
|
364
|
+
redirectStripSlash
|
|
365
|
+
)
|
|
366
|
+
app.put(
|
|
367
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
|
|
368
|
+
ChunkRequest.put
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
// Head Chunk. Declared before the GET route for the same reason as Head
|
|
372
|
+
// Resource: serve Content-Type/Content-Length from stored metadata without
|
|
373
|
+
// opening the byte stream.
|
|
374
|
+
app.head(
|
|
375
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
|
|
376
|
+
ChunkRequest.head
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
// Get a chunk's bytes
|
|
380
|
+
app.get(
|
|
381
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
|
|
382
|
+
ChunkRequest.get
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
// Delete a chunk
|
|
386
|
+
app.delete(
|
|
387
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex',
|
|
388
|
+
ChunkRequest.delete
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
// List a Resource's chunks (container form; trailing slash is canonical)
|
|
392
|
+
app.get(
|
|
393
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks', // trailing slash required
|
|
394
|
+
redirectAddSlash
|
|
395
|
+
)
|
|
396
|
+
app.get(
|
|
397
|
+
'/space/:spaceId/:collectionId/:resourceId/chunks/',
|
|
398
|
+
ChunkRequest.list
|
|
399
|
+
)
|
|
354
400
|
}
|
|
355
401
|
|
|
356
402
|
/**
|
package/src/types.ts
CHANGED
|
@@ -645,7 +645,9 @@ export interface StorageBackend {
|
|
|
645
645
|
* Deletes a Resource. When `ifMatch` is supplied (`conditional-writes`), the
|
|
646
646
|
* delete proceeds only if the Resource's current ETag matches, evaluated
|
|
647
647
|
* atomically with the removal; a mismatch rejects with `precondition-failed`
|
|
648
|
-
* (412). Without it, the delete is unconditional and idempotent.
|
|
648
|
+
* (412). Without it, the delete is unconditional and idempotent. Deleting a
|
|
649
|
+
* Resource cascade-deletes any chunks stored under it (the `chunked-streams`
|
|
650
|
+
* feature), so chunks never outlive their parent.
|
|
649
651
|
*/
|
|
650
652
|
deleteResource(options: {
|
|
651
653
|
spaceId: string
|
|
@@ -694,6 +696,81 @@ export interface StorageBackend {
|
|
|
694
696
|
ifNoneMatch?: boolean
|
|
695
697
|
}): Promise<{ metaVersion: number } | undefined>
|
|
696
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Writes one chunk of a chunked Resource (the `chunked-streams` feature),
|
|
701
|
+
* keyed by `(spaceId, collectionId, resourceId, chunkIndex)`. The chunk body
|
|
702
|
+
* is opaque bytes + content-type -- stored exactly like a binary Resource
|
|
703
|
+
* representation (same upload cap / quota guards); the server never parses
|
|
704
|
+
* it. The parent Resource MUST already exist: writing a chunk of an absent
|
|
705
|
+
* Resource rejects with `ResourceNotFoundError` (404), so orphan chunks
|
|
706
|
+
* cannot accumulate. An upsert per chunk, bumping the chunk's own monotonic
|
|
707
|
+
* `version` (its ETag validator, independent of the parent's); an `ifMatch` /
|
|
708
|
+
* `ifNoneMatch` precondition is evaluated on that chunk version atomically
|
|
709
|
+
* with the write (`precondition-failed` 412 on mismatch).
|
|
710
|
+
*/
|
|
711
|
+
writeChunk(options: {
|
|
712
|
+
spaceId: string
|
|
713
|
+
collectionId: string
|
|
714
|
+
resourceId: string
|
|
715
|
+
chunkIndex: number
|
|
716
|
+
input: ResourceInput
|
|
717
|
+
ifMatch?: string
|
|
718
|
+
ifNoneMatch?: boolean
|
|
719
|
+
}): Promise<{ version: number }>
|
|
720
|
+
/** Reads a chunk's bytes; rejects with `ResourceNotFoundError` when absent. */
|
|
721
|
+
getChunk(options: {
|
|
722
|
+
spaceId: string
|
|
723
|
+
collectionId: string
|
|
724
|
+
resourceId: string
|
|
725
|
+
chunkIndex: number
|
|
726
|
+
}): Promise<ResourceResult>
|
|
727
|
+
/**
|
|
728
|
+
* Reads a chunk's stored content-type / size / version (the HEAD payload
|
|
729
|
+
* headers). Resolves `undefined` when the chunk is absent.
|
|
730
|
+
*/
|
|
731
|
+
getChunkMetadata(options: {
|
|
732
|
+
spaceId: string
|
|
733
|
+
collectionId: string
|
|
734
|
+
resourceId: string
|
|
735
|
+
chunkIndex: number
|
|
736
|
+
}): Promise<
|
|
737
|
+
{ contentType: string; size: number; version?: number } | undefined
|
|
738
|
+
>
|
|
739
|
+
/**
|
|
740
|
+
* Deletes one chunk. Resolves `true` when a chunk was removed and `false`
|
|
741
|
+
* when none was stored at that index (the handler 404s on `false` -- unlike
|
|
742
|
+
* `deleteResource`, chunk deletes are not silently idempotent, mirroring the
|
|
743
|
+
* EDV chunk contract). An `ifMatch` precondition is evaluated atomically
|
|
744
|
+
* with the removal (`precondition-failed` 412 on mismatch).
|
|
745
|
+
*/
|
|
746
|
+
deleteChunk(options: {
|
|
747
|
+
spaceId: string
|
|
748
|
+
collectionId: string
|
|
749
|
+
resourceId: string
|
|
750
|
+
chunkIndex: number
|
|
751
|
+
ifMatch?: string
|
|
752
|
+
}): Promise<boolean>
|
|
753
|
+
/**
|
|
754
|
+
* Lists a Resource's stored chunks in ascending `index` order -- the
|
|
755
|
+
* discovery/reassembly listing (the server never reassembles; a reader
|
|
756
|
+
* learns the chunk set here). Resolves an empty listing when the Resource
|
|
757
|
+
* has no chunks (including when the Resource itself is absent -- existence
|
|
758
|
+
* is the parent routes' concern).
|
|
759
|
+
*/
|
|
760
|
+
listChunks(options: {
|
|
761
|
+
spaceId: string
|
|
762
|
+
collectionId: string
|
|
763
|
+
resourceId: string
|
|
764
|
+
}): Promise<{
|
|
765
|
+
count: number
|
|
766
|
+
chunks: Array<{
|
|
767
|
+
index: number
|
|
768
|
+
size: number
|
|
769
|
+
contentType: string
|
|
770
|
+
version?: number
|
|
771
|
+
}>
|
|
772
|
+
}>
|
|
773
|
+
|
|
697
774
|
/**
|
|
698
775
|
* OPTIONAL replication change feed (the `changes` query profile.
|
|
699
776
|
* Returns the Collection's JSON-document
|