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
@@ -44,7 +44,9 @@ import { collectionPath, resourcePath } from '../lib/paths.js'
44
44
  import {
45
45
  encodeFilenameSegment,
46
46
  fileNameFor,
47
- parseResourceFileName
47
+ parseResourceFileName,
48
+ chunkDirName,
49
+ CHUNK_DIR_PREFIX
48
50
  } from '../lib/resourceFileName.js'
49
51
  import { sanitizeBackendRecord } from '../lib/backends.js'
50
52
  import { backendUsageFields } from '../lib/backendUsage.js'
@@ -373,8 +375,9 @@ export class FileSystemBackend implements StorageBackend {
373
375
  * It advertises the `conditional-writes` affordance: it exposes a per-Resource
374
376
  * `version` as an HTTP `ETag` validator and honors `If-Match` / `If-None-Match`
375
377
  * write preconditions atomically (returning `412 precondition-failed` on a
376
- * mismatch). The remaining `features` vocabulary token (`chunked-streams`)
377
- * is not implemented yet and is added when it lands.
378
+ * mismatch). It also advertises `chunked-streams`: chunk addressing for large
379
+ * Resources (`/{resourceId}/chunks/{n}`), each chunk stored opaquely like a
380
+ * binary Resource representation.
378
381
  * (Client-side encryption is deliberately not a backend feature: encrypted
379
382
  * documents are opaque client-encrypted JSON this backend already stores
380
383
  * faithfully, with no server cooperation.)
@@ -399,11 +402,14 @@ export class FileSystemBackend implements StorageBackend {
399
402
  // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
400
403
  // keys on the `encryption` marker, a client-declared `epoch` stamp on
401
404
  // Resources, and conditional (`If-Match`) Collection Description writes.
405
+ // `chunked-streams`: chunk addressing (`/{resourceId}/chunks/{n}`) for a
406
+ // large Resource, each chunk stored opaquely (bytes + content-type).
402
407
  features: [
403
408
  'conditional-writes',
404
409
  'changes-query',
405
410
  'blinded-index-query',
406
- 'key-epochs'
411
+ 'key-epochs',
412
+ 'chunked-streams'
407
413
  ]
408
414
  }
409
415
  }
@@ -1096,6 +1102,14 @@ export class FileSystemBackend implements StorageBackend {
1096
1102
  spaceEntries.sort((a, b) => a.name.localeCompare(b.name))
1097
1103
 
1098
1104
  const collectionEntriesByDir: Record<string, typeof spaceEntries> = {}
1105
+ // Per-Resource chunk directories (`.chunks.<encId>/`; the `chunked-streams`
1106
+ // feature) are subdirectories of a Collection dir, so the file filter below
1107
+ // skips them. Gather each one's files here so a chunked Resource's chunks
1108
+ // travel in the export, keyed by Collection then by chunk-directory name.
1109
+ const chunkDirsByCollection: Record<
1110
+ string,
1111
+ Array<{ dirName: string; files: string[] }>
1112
+ > = {}
1099
1113
  for (const entry of spaceEntries) {
1100
1114
  if (!entry.isDirectory()) {
1101
1115
  continue
@@ -1107,6 +1121,22 @@ export class FileSystemBackend implements StorageBackend {
1107
1121
  collectionEntriesByDir[entry.name] = entries
1108
1122
  .filter(e => e.isFile())
1109
1123
  .sort((a, b) => a.name.localeCompare(b.name))
1124
+ const chunkDirs: Array<{ dirName: string; files: string[] }> = []
1125
+ for (const sub of entries
1126
+ .filter(e => e.isDirectory() && e.name.startsWith(CHUNK_DIR_PREFIX))
1127
+ .sort((a, b) => a.name.localeCompare(b.name))) {
1128
+ const files = (
1129
+ await fs.promises.readdir(
1130
+ path.join(sourceSpaceDir, entry.name, sub.name),
1131
+ { withFileTypes: true }
1132
+ )
1133
+ )
1134
+ .filter(e => e.isFile())
1135
+ .map(e => e.name)
1136
+ .sort((a, b) => a.localeCompare(b))
1137
+ chunkDirs.push({ dirName: sub.name, files })
1138
+ }
1139
+ chunkDirsByCollection[entry.name] = chunkDirs
1110
1140
  }
1111
1141
 
1112
1142
  // Space-scoped zcap revocations travel with the export. They live in the
@@ -1135,9 +1165,18 @@ export class FileSystemBackend implements StorageBackend {
1135
1165
  entry.isDirectory()
1136
1166
  ? {
1137
1167
  name: entry.name,
1138
- files: (collectionEntriesByDir[entry.name] ?? []).map(
1139
- file => file.name
1140
- )
1168
+ // The Collection's own files, then each chunked Resource's chunk
1169
+ // files listed by their `.chunks.<encId>/<file>` relative path
1170
+ // (the manifest mirrors the pack order below).
1171
+ files: [
1172
+ ...(collectionEntriesByDir[entry.name] ?? []).map(
1173
+ file => file.name
1174
+ ),
1175
+ ...(chunkDirsByCollection[entry.name] ?? []).flatMap(
1176
+ ({ dirName, files }) =>
1177
+ files.map(file => `${dirName}/${file}`)
1178
+ )
1179
+ ]
1141
1180
  }
1142
1181
  : // top-level files in space (e.g. .space.<spaceId>.json)
1143
1182
  { name: entry.name }
@@ -1164,6 +1203,25 @@ export class FileSystemBackend implements StorageBackend {
1164
1203
  )
1165
1204
  pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes)
1166
1205
  }
1206
+ // A chunked Resource's chunk directory and its files, packed under the
1207
+ // Collection so `.chunks.<encId>/<file>` round-trips on import.
1208
+ for (const { dirName, files } of chunkDirsByCollection[entry.name] ??
1209
+ []) {
1210
+ pack.entry({
1211
+ name: `${entryTarget}/${dirName}/`,
1212
+ type: 'directory',
1213
+ mtime
1214
+ })
1215
+ for (const file of files) {
1216
+ const bytes = await fs.promises.readFile(
1217
+ path.join(sourceSpaceDir, entry.name, dirName, file)
1218
+ )
1219
+ pack.entry(
1220
+ { name: `${entryTarget}/${dirName}/${file}`, mtime },
1221
+ bytes
1222
+ )
1223
+ }
1224
+ }
1167
1225
  } else if (entry.isFile()) {
1168
1226
  const bytes = await fs.promises.readFile(
1169
1227
  path.join(sourceSpaceDir, entry.name)
@@ -1226,7 +1284,8 @@ export class FileSystemBackend implements StorageBackend {
1226
1284
  for (const {
1227
1285
  collectionId,
1228
1286
  collectionDescription,
1229
- resources
1287
+ resources,
1288
+ chunkFiles
1230
1289
  } of collections) {
1231
1290
  const existing = await this.getCollectionDescription({
1232
1291
  spaceId,
@@ -1259,6 +1318,19 @@ export class FileSystemBackend implements StorageBackend {
1259
1318
  }
1260
1319
  incomingBytes += body.length
1261
1320
  }
1321
+ // Chunk files (the `chunked-streams` feature) inherit the per-upload cap
1322
+ // and quota estimate, but NOT the encryption-conformance check: a chunk is
1323
+ // opaque bytes, not a JSON envelope of the Collection's scheme.
1324
+ for (const { body } of chunkFiles) {
1325
+ if (maxUploadBytes !== undefined && body.length > maxUploadBytes) {
1326
+ throw new PayloadTooLargeError({
1327
+ maxUploadBytes,
1328
+ backendId: this.describe().id,
1329
+ uploadBytes: body.length
1330
+ })
1331
+ }
1332
+ incomingBytes += body.length
1333
+ }
1262
1334
  }
1263
1335
  if (capacityBytes !== undefined) {
1264
1336
  await this._assertSpaceHeadroom({
@@ -1309,7 +1381,8 @@ export class FileSystemBackend implements StorageBackend {
1309
1381
  collectionPolicy,
1310
1382
  resources,
1311
1383
  resourcePolicies,
1312
- resourceMetadata
1384
+ resourceMetadata,
1385
+ chunkFiles
1313
1386
  } of collections) {
1314
1387
  // check if collection already exists
1315
1388
  const collectionExisted = Boolean(
@@ -1451,6 +1524,43 @@ export class FileSystemBackend implements StorageBackend {
1451
1524
  })
1452
1525
  stats.resourcesCreated++
1453
1526
  }
1527
+
1528
+ // Restore chunk files of chunked Resources (the `chunked-streams` feature)
1529
+ // into their per-Resource chunk directories. Skip-not-overwrite, per chunk
1530
+ // file: an existing chunk file is left untouched, so a re-import never
1531
+ // clobbers stored chunk bytes (or their version sidecar). An ORPHAN chunk
1532
+ // file -- one whose parent Resource is absent or a tombstone (no live
1533
+ // representation on the destination after the Resource apply loop above)
1534
+ // -- is skipped rather than resurrected, matching the live write path's
1535
+ // parent-exists rule (and the Postgres import).
1536
+ const parentIsLive = new Map<string, boolean>()
1537
+ for (const { resourceId, fileName, body } of chunkFiles) {
1538
+ let live = parentIsLive.get(resourceId)
1539
+ if (live === undefined) {
1540
+ live = Boolean(await this._findFile({ collectionDir, resourceId }))
1541
+ parentIsLive.set(resourceId, live)
1542
+ }
1543
+ if (!live) {
1544
+ continue
1545
+ }
1546
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
1547
+ const target = path.join(chunkDir, fileName)
1548
+ this._assertContained(target)
1549
+ let present = false
1550
+ try {
1551
+ await fsStat(target)
1552
+ present = true
1553
+ } catch (err) {
1554
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
1555
+ throw err
1556
+ }
1557
+ }
1558
+ if (present) {
1559
+ continue
1560
+ }
1561
+ await mkdir(chunkDir, { recursive: true })
1562
+ await atomicWriteFile({ filePath: target, data: body })
1563
+ }
1454
1564
  }
1455
1565
 
1456
1566
  // Restore the archive's Space-scoped zcap revocations under this Space's
@@ -2009,6 +2119,83 @@ export class FileSystemBackend implements StorageBackend {
2009
2119
  }
2010
2120
  }
2011
2121
 
2122
+ await this._writeRepresentationBytes({ spaceId, filePath, input })
2123
+
2124
+ // A Resource has a single current representation: remove any prior
2125
+ // representation stored under a different content-type (its filename
2126
+ // differs). Write-new-then-prune (not delete-then-write) so the resource is
2127
+ // never momentarily absent.
2128
+ const existing = await this._resourceFilesFor({ collectionDir, resourceId })
2129
+ await Promise.all(
2130
+ existing
2131
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
2132
+ .map(name => rm(name))
2133
+ )
2134
+
2135
+ // Maintain the server-managed timestamps and the monotonic `version`: a
2136
+ // content write sets `createdAt` on first write, bumps `updatedAt`, and
2137
+ // increments `version` (the ETag validator) from its prior value, preserving
2138
+ // any user-writable `custom` and the independent `metaVersion` already stored
2139
+ // in the sidecar (a content write does not touch the metadata sub-resource).
2140
+ //
2141
+ // `createdBy` pairs with `createdAt`: taken from this write's invoker only
2142
+ // when this write creates the sidecar, so it names the creator rather than
2143
+ // the last writer, and is preserved verbatim afterward -- including
2144
+ // preserved-as-absent, so a Resource created with no invoker never has a
2145
+ // later writer backfilled into it. A tombstone keeps both, so re-creating a
2146
+ // deleted id under a different invoker preserves the original creator, as
2147
+ // it does the original `createdAt`.
2148
+ const now = new Date().toISOString()
2149
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2150
+ const version = (prior?.version ?? 0) + 1
2151
+ const creator = prior ? prior.createdBy : createdBy
2152
+ await this._writeMetaSidecar({
2153
+ collectionDir,
2154
+ resourceId,
2155
+ sidecar: {
2156
+ createdAt: prior?.createdAt ?? now,
2157
+ updatedAt: now,
2158
+ ...(creator !== undefined && { createdBy: creator }),
2159
+ version,
2160
+ ...(prior?.metaVersion !== undefined && {
2161
+ metaVersion: prior.metaVersion
2162
+ }),
2163
+ ...(prior?.custom && { custom: prior.custom }),
2164
+ // The key-epoch stamp is set from this write's declaration and CLEARED
2165
+ // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
2166
+ // worse than none), so it is NOT preserved from `prior` like `custom`.
2167
+ ...(epoch !== undefined && { epoch })
2168
+ }
2169
+ })
2170
+ return { version }
2171
+ }
2172
+
2173
+ /**
2174
+ * Writes a representation body (JSON value or byte stream) to `filePath`,
2175
+ * applying the same size guards every write path shares: the per-upload cap
2176
+ * (413 `PayloadTooLargeError`) and, when a byte quota is configured, the
2177
+ * Space headroom (507). A JSON body is fully in memory, so its size is checked
2178
+ * up front and written atomically; a binary body is streamed through the cap /
2179
+ * quota guards into a temp file and durably committed (fsync + rename + dir
2180
+ * fsync), removing the partial file on any failure. Shared by the Resource
2181
+ * write path (`_writeResourceLocked`) and the chunk write path
2182
+ * (`_writeChunkLocked`); the caller has already resolved `filePath` and ensured
2183
+ * its parent directory exists for the streamed case.
2184
+ * @param options {object}
2185
+ * @param options.spaceId {string}
2186
+ * @param options.filePath {string} absolute path of the representation file
2187
+ * @param options.input {ResourceInput}
2188
+ * @returns {Promise<void>}
2189
+ */
2190
+ private async _writeRepresentationBytes({
2191
+ spaceId,
2192
+ filePath,
2193
+ input
2194
+ }: {
2195
+ spaceId: string
2196
+ filePath: string
2197
+ input: ResourceInput
2198
+ }): Promise<void> {
2012
2199
  const { capacityBytes, maxUploadBytes } = this
2013
2200
 
2014
2201
  if (input.kind === 'json') {
@@ -2098,54 +2285,6 @@ export class FileSystemBackend implements StorageBackend {
2098
2285
  throw err
2099
2286
  }
2100
2287
  }
2101
-
2102
- // A Resource has a single current representation: remove any prior
2103
- // representation stored under a different content-type (its filename
2104
- // differs). Write-new-then-prune (not delete-then-write) so the resource is
2105
- // never momentarily absent.
2106
- const existing = await this._resourceFilesFor({ collectionDir, resourceId })
2107
- await Promise.all(
2108
- existing
2109
- .filter(name => path.resolve(name) !== path.resolve(filePath))
2110
- .map(name => rm(name))
2111
- )
2112
-
2113
- // Maintain the server-managed timestamps and the monotonic `version`: a
2114
- // content write sets `createdAt` on first write, bumps `updatedAt`, and
2115
- // increments `version` (the ETag validator) from its prior value, preserving
2116
- // any user-writable `custom` and the independent `metaVersion` already stored
2117
- // in the sidecar (a content write does not touch the metadata sub-resource).
2118
- //
2119
- // `createdBy` pairs with `createdAt`: taken from this write's invoker only
2120
- // when this write creates the sidecar, so it names the creator rather than
2121
- // the last writer, and is preserved verbatim afterward -- including
2122
- // preserved-as-absent, so a Resource created with no invoker never has a
2123
- // later writer backfilled into it. A tombstone keeps both, so re-creating a
2124
- // deleted id under a different invoker preserves the original creator, as
2125
- // it does the original `createdAt`.
2126
- const now = new Date().toISOString()
2127
- const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2128
- const version = (prior?.version ?? 0) + 1
2129
- const creator = prior ? prior.createdBy : createdBy
2130
- await this._writeMetaSidecar({
2131
- collectionDir,
2132
- resourceId,
2133
- sidecar: {
2134
- createdAt: prior?.createdAt ?? now,
2135
- updatedAt: now,
2136
- ...(creator !== undefined && { createdBy: creator }),
2137
- version,
2138
- ...(prior?.metaVersion !== undefined && {
2139
- metaVersion: prior.metaVersion
2140
- }),
2141
- ...(prior?.custom && { custom: prior.custom }),
2142
- // The key-epoch stamp is set from this write's declaration and CLEARED
2143
- // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
2144
- // worse than none), so it is NOT preserved from `prior` like `custom`.
2145
- ...(epoch !== undefined && { epoch })
2146
- }
2147
- })
2148
- return { version }
2149
2288
  }
2150
2289
 
2151
2290
  /**
@@ -2598,6 +2737,15 @@ export class FileSystemBackend implements StorageBackend {
2598
2737
  )
2599
2738
  // Drop the content representation(s) but KEEP the sidecar as the tombstone.
2600
2739
  await Promise.all(filesForResource.map(filename => rm(filename)))
2740
+ // Cascade-delete the Resource's chunks (the `chunked-streams` feature): a
2741
+ // chunk must never outlive its parent Resource, so its whole chunk
2742
+ // directory goes with the content. Runs under the same per-Resource lock a
2743
+ // `writeChunk` takes, so a chunk write racing this delete cannot re-create
2744
+ // an orphan directory. `force` makes an absent chunk directory a no-op.
2745
+ await rm(this._chunkDir({ collectionDir, resourceId }), {
2746
+ recursive: true,
2747
+ force: true
2748
+ })
2601
2749
  // Bump `version` / `updatedAt` so the tombstone sorts after the Resource's
2602
2750
  // prior state in the change feed, and continues the monotonic version (a
2603
2751
  // later re-create reads this sidecar and keeps counting up). `custom` is
@@ -2630,6 +2778,439 @@ export class FileSystemBackend implements StorageBackend {
2630
2778
  )
2631
2779
  }
2632
2780
 
2781
+ // Chunks (the `chunked-streams` feature)
2782
+
2783
+ /**
2784
+ * Builds the on-disk path for a Resource's chunk directory
2785
+ * (`.chunks.<encodedResourceId>/`) inside its Collection dir. A hidden
2786
+ * subdirectory (leading `.`, so it is invisible to the `r.`-prefixed Collection
2787
+ * listing and the live-Resource count) that holds the Resource's chunk
2788
+ * representations; inside it a chunk is stored exactly like a Resource keyed by
2789
+ * its index (`r.<index>.<encodedContentType>.<ext>` plus a `.meta.<index>.json`
2790
+ * version sidecar), so the Resource file / sidecar helpers are reused verbatim
2791
+ * with the chunk directory as the `collectionDir`.
2792
+ * @param options {object}
2793
+ * @param options.collectionDir {string}
2794
+ * @param options.resourceId {string}
2795
+ * @returns {string}
2796
+ */
2797
+ _chunkDir({
2798
+ collectionDir,
2799
+ resourceId
2800
+ }: {
2801
+ collectionDir: string
2802
+ resourceId: string
2803
+ }): string {
2804
+ const chunkDir = path.join(collectionDir, chunkDirName(resourceId))
2805
+ this._assertContained(chunkDir)
2806
+ return chunkDir
2807
+ }
2808
+
2809
+ /**
2810
+ * Writes one chunk of a chunked Resource, keyed by
2811
+ * `(spaceId, collectionId, resourceId, chunkIndex)`. The parent Resource MUST
2812
+ * already exist (else `ResourceNotFoundError`, 404), checked under the same
2813
+ * per-Resource lock a `deleteResource` cascade takes so a chunk can never be
2814
+ * orphaned by a racing delete. The body is stored opaquely (bytes +
2815
+ * content-type) through the shared upload-cap / quota guards, and the chunk's
2816
+ * own monotonic `version` (its ETag validator, independent of the parent's) is
2817
+ * bumped; any `If-Match` / `If-None-Match` precondition is evaluated on that
2818
+ * version atomically with the write (`PreconditionFailedError`, 412).
2819
+ * @param options {object}
2820
+ * @param options.spaceId {string}
2821
+ * @param options.collectionId {string}
2822
+ * @param options.resourceId {string}
2823
+ * @param options.chunkIndex {number} non-negative integer chunk position
2824
+ * @param options.input {ResourceInput}
2825
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
2826
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
2827
+ * @returns {Promise<{ version: number }>} the chunk's new version
2828
+ */
2829
+ async writeChunk({
2830
+ spaceId,
2831
+ collectionId,
2832
+ resourceId,
2833
+ chunkIndex,
2834
+ input,
2835
+ ifMatch,
2836
+ ifNoneMatch
2837
+ }: {
2838
+ spaceId: string
2839
+ collectionId: string
2840
+ resourceId: string
2841
+ chunkIndex: number
2842
+ input: ResourceInput
2843
+ ifMatch?: string
2844
+ ifNoneMatch?: boolean
2845
+ }): Promise<{ version: number }> {
2846
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2847
+ // Serialize on the parent Resource's lock key -- the same key
2848
+ // `deleteResource` takes -- so the parent-exists check, the write, and the
2849
+ // cascade delete cannot interleave (no orphan chunk).
2850
+ return this._writeMutex.run(
2851
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
2852
+ () =>
2853
+ this._writeChunkLocked({
2854
+ spaceId,
2855
+ collectionDir,
2856
+ resourceId,
2857
+ chunkIndex,
2858
+ input,
2859
+ ifMatch,
2860
+ ifNoneMatch
2861
+ })
2862
+ )
2863
+ }
2864
+
2865
+ /**
2866
+ * The critical section of `writeChunk`, run under the per-Resource lock. See
2867
+ * `writeChunk` for the parameters.
2868
+ * @returns {Promise<{ version: number }>}
2869
+ */
2870
+ private async _writeChunkLocked({
2871
+ spaceId,
2872
+ collectionDir,
2873
+ resourceId,
2874
+ chunkIndex,
2875
+ input,
2876
+ ifMatch,
2877
+ ifNoneMatch
2878
+ }: {
2879
+ spaceId: string
2880
+ collectionDir: string
2881
+ resourceId: string
2882
+ chunkIndex: number
2883
+ input: ResourceInput
2884
+ ifMatch?: string
2885
+ ifNoneMatch?: boolean
2886
+ }): Promise<{ version: number }> {
2887
+ // The parent Resource must exist: writing a chunk of an absent Resource
2888
+ // rejects, so orphan chunks cannot accumulate.
2889
+ const parentExists =
2890
+ (await this._findFile({ collectionDir, resourceId })) !== undefined
2891
+ if (!parentExists) {
2892
+ throw new ResourceNotFoundError({ requestName: 'Write Chunk' })
2893
+ }
2894
+
2895
+ // Inside the chunk directory a chunk is a Resource keyed by its index, so the
2896
+ // Resource file / sidecar helpers apply with `chunkDir` as the collectionDir
2897
+ // and the stringified index as the resourceId.
2898
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
2899
+ const chunkId = String(chunkIndex)
2900
+ const filename = fileNameFor({
2901
+ resourceId: chunkId,
2902
+ contentType: input.contentType
2903
+ })
2904
+ const filePath = path.join(chunkDir, filename)
2905
+ this._assertContained(filePath)
2906
+
2907
+ // Evaluate any precondition against the chunk's current version before
2908
+ // writing (still inside the lock, so check and write are atomic).
2909
+ if (ifMatch !== undefined || ifNoneMatch) {
2910
+ await this._assertWritePrecondition({
2911
+ collectionDir: chunkDir,
2912
+ resourceId: chunkId,
2913
+ ifMatch,
2914
+ ifNoneMatch
2915
+ })
2916
+ }
2917
+
2918
+ // The chunk directory is created lazily on first write (a binary body is
2919
+ // streamed into a temp file in it, so it must exist first).
2920
+ await mkdir(chunkDir, { recursive: true })
2921
+ await this._writeRepresentationBytes({ spaceId, filePath, input })
2922
+
2923
+ // A chunk has a single current representation: remove any prior one stored
2924
+ // under a different content-type (write-new-then-prune).
2925
+ const existing = await this._resourceFilesFor({
2926
+ collectionDir: chunkDir,
2927
+ resourceId: chunkId
2928
+ })
2929
+ await Promise.all(
2930
+ existing
2931
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
2932
+ .map(name => rm(name))
2933
+ )
2934
+
2935
+ // Bump the chunk's monotonic `version` (its ETag validator), preserving its
2936
+ // `createdAt`. A chunk carries no user Metadata / `createdBy` / epoch stamp.
2937
+ const now = new Date().toISOString()
2938
+ const prior = await this._readMetaSidecar({
2939
+ collectionDir: chunkDir,
2940
+ resourceId: chunkId
2941
+ })
2942
+ const version = (prior?.version ?? 0) + 1
2943
+ await this._writeMetaSidecar({
2944
+ collectionDir: chunkDir,
2945
+ resourceId: chunkId,
2946
+ sidecar: {
2947
+ createdAt: prior?.createdAt ?? now,
2948
+ updatedAt: now,
2949
+ version
2950
+ }
2951
+ })
2952
+ return { version }
2953
+ }
2954
+
2955
+ /**
2956
+ * Reads a chunk's bytes, resolving a `ResourceResult` (stream + resolved
2957
+ * content-type + the chunk's `version`). Throws `ResourceNotFoundError` (404)
2958
+ * when the chunk is absent.
2959
+ * @param options {object}
2960
+ * @param options.spaceId {string}
2961
+ * @param options.collectionId {string}
2962
+ * @param options.resourceId {string}
2963
+ * @param options.chunkIndex {number}
2964
+ * @returns {Promise<ResourceResult>}
2965
+ */
2966
+ async getChunk({
2967
+ spaceId,
2968
+ collectionId,
2969
+ resourceId,
2970
+ chunkIndex
2971
+ }: {
2972
+ spaceId: string
2973
+ collectionId: string
2974
+ resourceId: string
2975
+ chunkIndex: number
2976
+ }): Promise<ResourceResult> {
2977
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2978
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
2979
+ const chunkId = String(chunkIndex)
2980
+ const filePath = await this._findFile({
2981
+ collectionDir: chunkDir,
2982
+ resourceId: chunkId
2983
+ })
2984
+ if (!filePath) {
2985
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
2986
+ }
2987
+
2988
+ try {
2989
+ await fsStat(filePath)
2990
+ } catch (err) {
2991
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
2992
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
2993
+ }
2994
+ throw err
2995
+ }
2996
+
2997
+ const { contentType: storedResourceType } = parseResourceFileName(
2998
+ path.basename(filePath)
2999
+ )
3000
+ const sidecar = await this._readMetaSidecar({
3001
+ collectionDir: chunkDir,
3002
+ resourceId: chunkId
3003
+ })
3004
+ return {
3005
+ resourceStream: await openFileStream(filePath, this.logger),
3006
+ storedResourceType,
3007
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3008
+ }
3009
+ }
3010
+
3011
+ /**
3012
+ * Reads a chunk's stored content-type / size / version (the HEAD payload
3013
+ * headers). Resolves `undefined` when the chunk is absent.
3014
+ * @param options {object}
3015
+ * @param options.spaceId {string}
3016
+ * @param options.collectionId {string}
3017
+ * @param options.resourceId {string}
3018
+ * @param options.chunkIndex {number}
3019
+ * @returns {Promise<{ contentType: string, size: number, version?: number } |
3020
+ * undefined>}
3021
+ */
3022
+ async getChunkMetadata({
3023
+ spaceId,
3024
+ collectionId,
3025
+ resourceId,
3026
+ chunkIndex
3027
+ }: {
3028
+ spaceId: string
3029
+ collectionId: string
3030
+ resourceId: string
3031
+ chunkIndex: number
3032
+ }): Promise<
3033
+ { contentType: string; size: number; version?: number } | undefined
3034
+ > {
3035
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3036
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3037
+ const chunkId = String(chunkIndex)
3038
+ const filePath = await this._findFile({
3039
+ collectionDir: chunkDir,
3040
+ resourceId: chunkId
3041
+ })
3042
+ if (!filePath) {
3043
+ return undefined
3044
+ }
3045
+
3046
+ let stats
3047
+ try {
3048
+ stats = await fsStat(filePath)
3049
+ } catch (err) {
3050
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3051
+ return undefined
3052
+ }
3053
+ throw err
3054
+ }
3055
+
3056
+ const { contentType } = parseResourceFileName(path.basename(filePath))
3057
+ const sidecar = await this._readMetaSidecar({
3058
+ collectionDir: chunkDir,
3059
+ resourceId: chunkId
3060
+ })
3061
+ return {
3062
+ contentType,
3063
+ size: stats.size,
3064
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3065
+ }
3066
+ }
3067
+
3068
+ /**
3069
+ * Deletes one chunk (a hard delete: its bytes and version sidecar both go, and
3070
+ * -- unlike a Resource -- it leaves no tombstone, since chunks are not part of
3071
+ * the change feed). Resolves `true` when a chunk was removed and `false` when
3072
+ * none was stored at that index. When `ifMatch` is supplied it is evaluated on
3073
+ * the chunk's current version atomically with the removal (under the same
3074
+ * per-Resource lock), throwing `PreconditionFailedError` (412) on a mismatch.
3075
+ * @param options {object}
3076
+ * @param options.spaceId {string}
3077
+ * @param options.collectionId {string}
3078
+ * @param options.resourceId {string}
3079
+ * @param options.chunkIndex {number}
3080
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
3081
+ * @returns {Promise<boolean>}
3082
+ */
3083
+ async deleteChunk({
3084
+ spaceId,
3085
+ collectionId,
3086
+ resourceId,
3087
+ chunkIndex,
3088
+ ifMatch
3089
+ }: {
3090
+ spaceId: string
3091
+ collectionId: string
3092
+ resourceId: string
3093
+ chunkIndex: number
3094
+ ifMatch?: string
3095
+ }): Promise<boolean> {
3096
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3097
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3098
+ const chunkId = String(chunkIndex)
3099
+ return this._writeMutex.run(
3100
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
3101
+ async () => {
3102
+ const files = await this._resourceFilesFor({
3103
+ collectionDir: chunkDir,
3104
+ resourceId: chunkId
3105
+ })
3106
+ if (files.length === 0) {
3107
+ // Absent: the handler 404s on `false` (chunk deletes are not silently
3108
+ // idempotent, mirroring the EDV chunk contract).
3109
+ return false
3110
+ }
3111
+ if (ifMatch !== undefined) {
3112
+ await this._assertWritePrecondition({
3113
+ collectionDir: chunkDir,
3114
+ resourceId: chunkId,
3115
+ ifMatch
3116
+ })
3117
+ }
3118
+ await Promise.all(files.map(name => rm(name)))
3119
+ // Remove the version sidecar too: a chunk keeps no tombstone.
3120
+ await rm(
3121
+ this._metaSidecarPath({
3122
+ collectionDir: chunkDir,
3123
+ resourceId: chunkId
3124
+ }),
3125
+ { force: true }
3126
+ )
3127
+ // When that was the last chunk, remove the now-empty chunk directory
3128
+ // itself: a lingering empty `.chunks.<encId>/` would otherwise appear
3129
+ // in the export walk (diverging from a Postgres export of the same
3130
+ // logical state) and count its allocated block toward the du-based
3131
+ // quota measurement. Safe under the per-Resource lock (`writeChunk`
3132
+ // serializes on the same key, so nothing lands in the directory
3133
+ // between the check and the rmdir).
3134
+ const remaining = await fs.promises.readdir(chunkDir)
3135
+ if (remaining.length === 0) {
3136
+ await fs.promises.rmdir(chunkDir)
3137
+ }
3138
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
3139
+ this._usageCache.delete(spaceId)
3140
+ return true
3141
+ }
3142
+ )
3143
+ }
3144
+
3145
+ /**
3146
+ * Lists a Resource's stored chunks in ascending `index` order -- the
3147
+ * discovery/reassembly listing (the server never reassembles). Resolves an
3148
+ * empty listing when the Resource has no chunk directory (including when the
3149
+ * Resource itself is absent -- existence is the parent routes' concern).
3150
+ * @param options {object}
3151
+ * @param options.spaceId {string}
3152
+ * @param options.collectionId {string}
3153
+ * @param options.resourceId {string}
3154
+ * @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
3155
+ * number, contentType: string, version?: number }> }>}
3156
+ */
3157
+ async listChunks({
3158
+ spaceId,
3159
+ collectionId,
3160
+ resourceId
3161
+ }: {
3162
+ spaceId: string
3163
+ collectionId: string
3164
+ resourceId: string
3165
+ }): Promise<{
3166
+ count: number
3167
+ chunks: Array<{
3168
+ index: number
3169
+ size: number
3170
+ contentType: string
3171
+ version?: number
3172
+ }>
3173
+ }> {
3174
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3175
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3176
+ let entries: fs.Dirent[]
3177
+ try {
3178
+ entries = await fs.promises.readdir(chunkDir, { withFileTypes: true })
3179
+ } catch (err) {
3180
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3181
+ return { count: 0, chunks: [] }
3182
+ }
3183
+ throw err
3184
+ }
3185
+
3186
+ // Keep only chunk representations (`r.<index>.<type>.<ext>`), dropping the
3187
+ // `.meta.<index>.json` version sidecars.
3188
+ const chunkEntries = entries.filter(
3189
+ entry => entry.isFile() && entry.name.startsWith('r.')
3190
+ )
3191
+ const chunks = await Promise.all(
3192
+ chunkEntries.map(async entry => {
3193
+ const { resourceId: indexStr, contentType } = parseResourceFileName(
3194
+ entry.name
3195
+ )
3196
+ const filePath = path.join(chunkDir, entry.name)
3197
+ const stats = await fsStat(filePath)
3198
+ const sidecar = await this._readMetaSidecar({
3199
+ collectionDir: chunkDir,
3200
+ resourceId: indexStr
3201
+ })
3202
+ return {
3203
+ index: Number(indexStr),
3204
+ size: stats.size,
3205
+ contentType,
3206
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3207
+ }
3208
+ })
3209
+ )
3210
+ chunks.sort((left, right) => left.index - right.index)
3211
+ return { count: chunks.length, chunks }
3212
+ }
3213
+
2633
3214
  /**
2634
3215
  * Replication change feed (the `changes` query profile; see the
2635
3216
  * `StorageBackend.changesSince` contract.