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.
Files changed (52) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/dist/backends/filesystem.d.ts +161 -2
  3. package/dist/backends/filesystem.d.ts.map +1 -1
  4. package/dist/backends/filesystem.js +491 -60
  5. package/dist/backends/filesystem.js.map +1 -1
  6. package/dist/backends/postgres.d.ts +166 -0
  7. package/dist/backends/postgres.d.ts.map +1 -1
  8. package/dist/backends/postgres.js +625 -29
  9. package/dist/backends/postgres.js.map +1 -1
  10. package/dist/backends/postgresSchema.d.ts.map +1 -1
  11. package/dist/backends/postgresSchema.js +25 -0
  12. package/dist/backends/postgresSchema.js.map +1 -1
  13. package/dist/errors.d.ts +13 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +18 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/lib/exportManifest.d.ts +9 -0
  18. package/dist/lib/exportManifest.d.ts.map +1 -1
  19. package/dist/lib/exportManifest.js +9 -0
  20. package/dist/lib/exportManifest.js.map +1 -1
  21. package/dist/lib/importTar.d.ts +18 -2
  22. package/dist/lib/importTar.d.ts.map +1 -1
  23. package/dist/lib/importTar.js +77 -4
  24. package/dist/lib/importTar.js.map +1 -1
  25. package/dist/lib/paths.d.ts +33 -0
  26. package/dist/lib/paths.d.ts.map +1 -1
  27. package/dist/lib/paths.js +28 -0
  28. package/dist/lib/paths.js.map +1 -1
  29. package/dist/lib/resourceFileName.d.ts +43 -0
  30. package/dist/lib/resourceFileName.d.ts.map +1 -1
  31. package/dist/lib/resourceFileName.js +63 -0
  32. package/dist/lib/resourceFileName.js.map +1 -1
  33. package/dist/requests/ChunkRequest.d.ts +104 -0
  34. package/dist/requests/ChunkRequest.d.ts.map +1 -0
  35. package/dist/requests/ChunkRequest.js +396 -0
  36. package/dist/requests/ChunkRequest.js.map +1 -0
  37. package/dist/routes.d.ts.map +1 -1
  38. package/dist/routes.js +21 -0
  39. package/dist/routes.js.map +1 -1
  40. package/dist/types.d.ts +81 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/backends/filesystem.ts +638 -57
  44. package/src/backends/postgres.ts +867 -21
  45. package/src/backends/postgresSchema.ts +69 -57
  46. package/src/errors.ts +19 -0
  47. package/src/lib/importTar.ts +107 -4
  48. package/src/lib/paths.ts +48 -0
  49. package/src/lib/resourceFileName.ts +69 -0
  50. package/src/requests/ChunkRequest.ts +478 -0
  51. package/src/routes.ts +46 -0
  52. 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