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
@@ -42,9 +42,21 @@ import {
42
42
  DuplicateRevocationError
43
43
  } from '../errors.js'
44
44
  import { applyMigrations } from './postgresSchema.js'
45
- import { extractTarEntries, buildImportPlan } from '../lib/importTar.js'
45
+ import {
46
+ extractTarEntries,
47
+ buildImportPlan,
48
+ metaSidecarFileId
49
+ } from '../lib/importTar.js'
50
+ import type { TarEntry } from '../lib/importTar.js'
46
51
  import { collectionPath, resourcePath } from '../lib/paths.js'
47
- import { fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js'
52
+ import {
53
+ fileNameFor,
54
+ parseResourceFileName,
55
+ chunkDirName,
56
+ parseChunkDirName,
57
+ parseChunkIndexSegment
58
+ } from '../lib/resourceFileName.js'
59
+ import { assertValidId } from '../lib/validateId.js'
48
60
  import { sanitizeBackendRecord } from '../lib/backends.js'
49
61
  import { backendUsageFields } from '../lib/backendUsage.js'
50
62
  import { assertEncryptedWriteConforms } from '../lib/encryption.js'
@@ -206,6 +218,17 @@ function parseSidecar(bytes: Buffer | undefined): SidecarShape | undefined {
206
218
  }
207
219
  }
208
220
 
221
+ /**
222
+ * The chunk-metadata sidecar shape (`.chunks.<encId>/.meta.<index>.json`) the
223
+ * filesystem backend writes per chunk. Only the monotonic `version` (the
224
+ * chunk's ETag validator) is carried across export/import; the filesystem
225
+ * writes `createdAt` / `updatedAt` too, but this backend's `chunks` table holds
226
+ * no chunk timestamps, so it emits and reads only `version`.
227
+ */
228
+ interface ChunkSidecar {
229
+ version?: number
230
+ }
231
+
209
232
  export class PostgresBackend implements StorageBackend {
210
233
  logger: FastifyBaseLogger
211
234
  /**
@@ -421,11 +444,14 @@ export class PostgresBackend implements StorageBackend {
421
444
  // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
422
445
  // keys on the `encryption` marker, a client-declared `epoch` stamp on
423
446
  // Resources, and conditional (`If-Match`) Collection Description writes.
447
+ // `chunked-streams`: chunk addressing at `/{resourceId}/chunks/{n}`, opaque
448
+ // per-chunk raw-bytes storage in the `chunks` table.
424
449
  features: [
425
450
  'conditional-writes',
426
451
  'changes-query',
427
452
  'blinded-index-query',
428
- 'key-epochs'
453
+ 'key-epochs',
454
+ 'chunked-streams'
429
455
  ]
430
456
  }
431
457
  }
@@ -519,6 +545,42 @@ export class PostgresBackend implements StorageBackend {
519
545
  }
520
546
  }
521
547
 
548
+ /**
549
+ * Serializes concurrent CREATORS of one not-yet-existing row (a Resource or
550
+ * a chunk) on a transaction-scoped advisory lock keyed by the row's
551
+ * identity. Under READ COMMITTED a `SELECT ... FOR UPDATE` on an absent row
552
+ * locks nothing (no gap locks), so two concurrent creators would both read
553
+ * "no prior row" and both apply their full byte size as the usage delta --
554
+ * and while the `ON CONFLICT` arm recomputes `version` from the conflicting
555
+ * row, it cannot retroactively fix the loser's delta, leaving `usage_bytes`
556
+ * inflated by one write's size. The caller takes this lock when its
557
+ * lock-nothing SELECT found no row, then RE-reads the row: the second
558
+ * creator blocks here until the first commits, and its re-read sees the
559
+ * committed row, so its precondition, version, and usage delta are computed
560
+ * from accurate state. Held to commit (advisory xact lock). The `create:`
561
+ * prefix keeps this key domain distinct from the unique-blinded-term
562
+ * advisory lock, which hashes the bare `(spaceId, collectionId)`.
563
+ * @param options {object}
564
+ * @param options.client {pg.PoolClient}
565
+ * @param options.spaceId {string}
566
+ * @param options.rowKey {string} the row's identity within the Space
567
+ * @returns {Promise<void>}
568
+ */
569
+ private async _lockSameKeyCreate({
570
+ client,
571
+ spaceId,
572
+ rowKey
573
+ }: {
574
+ client: pg.PoolClient
575
+ spaceId: string
576
+ rowKey: string
577
+ }): Promise<void> {
578
+ await client.query(
579
+ 'SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))',
580
+ [spaceId, `create:${rowKey}`]
581
+ )
582
+ }
583
+
522
584
  // Quotas
523
585
 
524
586
  /**
@@ -546,13 +608,20 @@ export class PostgresBackend implements StorageBackend {
546
608
 
547
609
  let usageByCollection: CollectionUsage[] | undefined
548
610
  if (includeCollections) {
611
+ // Per-Collection usage sums both Resource content bytes and chunk bytes
612
+ // (the `chunked-streams` feature) so the breakdown agrees with the
613
+ // Space total in the transactional counter.
549
614
  const { rows: collectionRows } = await this._pool.query<{
550
615
  collection_id: string
551
616
  usage: string
552
617
  }>(
553
- `SELECT collection_id, COALESCE(SUM(size_bytes), 0) AS usage
554
- FROM resources
555
- WHERE space_id = $1
618
+ `SELECT collection_id, COALESCE(SUM(bytes), 0) AS usage FROM (
619
+ SELECT collection_id, size_bytes AS bytes FROM resources
620
+ WHERE space_id = $1
621
+ UNION ALL
622
+ SELECT collection_id, size AS bytes FROM chunks
623
+ WHERE space_id = $1
624
+ ) usage_rows
556
625
  GROUP BY collection_id
557
626
  ORDER BY collection_id`,
558
627
  [spaceId]
@@ -595,6 +664,8 @@ export class PostgresBackend implements StorageBackend {
595
664
  (SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
596
665
  AS space_total,
597
666
  (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
667
+ WHERE space_id = $1 AND collection_id = $2)
668
+ + (SELECT COALESCE(SUM(size), 0) FROM chunks
598
669
  WHERE space_id = $1 AND collection_id = $2) AS collection_total`,
599
670
  [spaceId, collectionId]
600
671
  )
@@ -1024,9 +1095,15 @@ export class PostgresBackend implements StorageBackend {
1024
1095
  collectionId: string
1025
1096
  }): Promise<void> {
1026
1097
  await this._withTransaction(async client => {
1098
+ // The Collection's freed bytes are its Resource content plus its chunk
1099
+ // bytes (the `chunked-streams` feature); both cascade away with the
1100
+ // Collection row, so both leave the quota counter.
1027
1101
  const { rows } = await client.query<{ total: string }>(
1028
- `SELECT COALESCE(SUM(size_bytes), 0) AS total FROM resources
1029
- WHERE space_id = $1 AND collection_id = $2`,
1102
+ `SELECT
1103
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
1104
+ WHERE space_id = $1 AND collection_id = $2)
1105
+ + (SELECT COALESCE(SUM(size), 0) FROM chunks
1106
+ WHERE space_id = $1 AND collection_id = $2) AS total`,
1030
1107
  [spaceId, collectionId]
1031
1108
  )
1032
1109
  const freedBytes = Number(rows[0]?.total ?? 0)
@@ -1307,7 +1384,31 @@ export class PostgresBackend implements StorageBackend {
1307
1384
  FOR UPDATE`,
1308
1385
  [spaceId, collectionId, resourceId]
1309
1386
  )
1310
- const prior = rows[0]
1387
+ let prior = rows[0]
1388
+ if (prior === undefined) {
1389
+ // The lock-nothing case: serialize with any concurrent creator of the
1390
+ // same id and re-read, so `exists` / `priorSize` below reflect the row
1391
+ // it committed (see `_lockSameKeyCreate` -- without this, both
1392
+ // creators would count their full byte size as the usage delta).
1393
+ await this._lockSameKeyCreate({
1394
+ client,
1395
+ spaceId,
1396
+ rowKey: `${collectionId}/${resourceId}`
1397
+ })
1398
+ const { rows: reread } = await client.query<
1399
+ Pick<
1400
+ ResourceRow,
1401
+ 'version' | 'size_bytes' | 'deleted' | 'created_at' | 'created_by'
1402
+ >
1403
+ >(
1404
+ `SELECT version, size_bytes, deleted, created_at, created_by
1405
+ FROM resources
1406
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1407
+ FOR UPDATE`,
1408
+ [spaceId, collectionId, resourceId]
1409
+ )
1410
+ prior = reread[0]
1411
+ }
1311
1412
  const exists = prior !== undefined && !prior.deleted
1312
1413
  if (ifMatch !== undefined || ifNoneMatch) {
1313
1414
  assertWritePrecondition({
@@ -1339,7 +1440,7 @@ export class PostgresBackend implements StorageBackend {
1339
1440
 
1340
1441
  const now = new Date().toISOString()
1341
1442
  const version = (prior?.version ?? 0) + 1
1342
- const priorSize = exists ? Number(prior.size_bytes) : 0
1443
+ const priorSize = exists ? Number(prior?.size_bytes ?? 0) : 0
1343
1444
  const delta = content.length - priorSize
1344
1445
  if (delta !== 0) {
1345
1446
  await this._applyUsageDelta({ client, spaceId, delta })
@@ -1350,10 +1451,11 @@ export class PostgresBackend implements StorageBackend {
1350
1451
  // dropped both (the metadata went with the deleted Resource).
1351
1452
  //
1352
1453
  // Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
1353
- // (a tombstone is a real row and stays lock-serialized), the SELECT FOR
1354
- // UPDATE locked nothing -- READ COMMITTED has no gap locks -- so a
1355
- // concurrent creator may have raced past the same precondition. A plain
1356
- // INSERT (no ON CONFLICT) makes the primary key the arbiter: the loser's
1454
+ // (a tombstone is a real row and stays lock-serialized), concurrent
1455
+ // creators through this method are already serialized by
1456
+ // `_lockSameKeyCreate` above -- but a writer that does not take that
1457
+ // lock (`importSpace`'s plain INSERTs) can still race. A plain INSERT
1458
+ // (no ON CONFLICT) keeps the primary key as the arbiter: the loser's
1357
1459
  // unique violation maps to the 412 the precondition would have thrown.
1358
1460
  // `createdBy` names the Resource's creator, not its last writer: taken
1359
1461
  // from this write's invoker only when there is no prior row at all
@@ -1519,7 +1621,25 @@ export class PostgresBackend implements StorageBackend {
1519
1621
  // no-op, keeping an existing tombstone's change-feed entry stable.
1520
1622
  return
1521
1623
  }
1522
- const freedBytes = Number(prior.size_bytes)
1624
+ // A soft delete is an UPDATE, not a row removal, so the chunk foreign
1625
+ // key's ON DELETE CASCADE does not fire -- remove the Resource's chunks
1626
+ // (the `chunked-streams` feature) explicitly in this same transaction so
1627
+ // they never outlive their parent, and return their bytes to the quota
1628
+ // counter alongside the Resource's content bytes.
1629
+ const { rows: chunkRows } = await client.query<{ total: string }>(
1630
+ `SELECT COALESCE(SUM(size), 0) AS total FROM chunks
1631
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1632
+ [spaceId, collectionId, resourceId]
1633
+ )
1634
+ const freedChunkBytes = Number(chunkRows[0]?.total ?? 0)
1635
+ if (freedChunkBytes > 0) {
1636
+ await client.query(
1637
+ `DELETE FROM chunks
1638
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1639
+ [spaceId, collectionId, resourceId]
1640
+ )
1641
+ }
1642
+ const freedBytes = Number(prior.size_bytes) + freedChunkBytes
1523
1643
  if (freedBytes > 0) {
1524
1644
  await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
1525
1645
  }
@@ -1671,6 +1791,395 @@ export class PostgresBackend implements StorageBackend {
1671
1791
  })
1672
1792
  }
1673
1793
 
1794
+ // Chunks (the `chunked-streams` feature)
1795
+
1796
+ /**
1797
+ * Writes one chunk of a chunked Resource as one transaction: the parent
1798
+ * Resource must exist (checked atomically -- a `FOR SHARE` lock on it also
1799
+ * blocks a concurrent delete of the parent for the duration of the write, so
1800
+ * a chunk can never be orphaned by a racing `deleteResource`), the chunk row
1801
+ * is locked, its precondition evaluated, its monotonic `version` bumped, and
1802
+ * the transactional quota delta applied. The chunk body is stored opaquely as
1803
+ * a single `bytea`, the buffered-blob path (bounded by `maxUploadBytes`),
1804
+ * exactly like a binary Resource representation.
1805
+ * @param options {object}
1806
+ * @param options.spaceId {string}
1807
+ * @param options.collectionId {string}
1808
+ * @param options.resourceId {string}
1809
+ * @param options.chunkIndex {number} a non-negative safe integer
1810
+ * @param options.input {ResourceInput}
1811
+ * @param [options.ifMatch] {string}
1812
+ * @param [options.ifNoneMatch] {boolean}
1813
+ * @returns {Promise<{ version: number }>} the chunk's new version
1814
+ */
1815
+ async writeChunk({
1816
+ spaceId,
1817
+ collectionId,
1818
+ resourceId,
1819
+ chunkIndex,
1820
+ input,
1821
+ ifMatch,
1822
+ ifNoneMatch
1823
+ }: {
1824
+ spaceId: string
1825
+ collectionId: string
1826
+ resourceId: string
1827
+ chunkIndex: number
1828
+ input: ResourceInput
1829
+ ifMatch?: string
1830
+ ifNoneMatch?: boolean
1831
+ }): Promise<{ version: number }> {
1832
+ const { maxUploadBytes } = this
1833
+ let bytes: Buffer
1834
+ if (input.kind === 'json') {
1835
+ bytes = Buffer.from(JSON.stringify(input.data))
1836
+ if (bytes.length > maxUploadBytes) {
1837
+ throw new PayloadTooLargeError({
1838
+ maxUploadBytes,
1839
+ backendId: this.describe().id,
1840
+ uploadBytes: bytes.length
1841
+ })
1842
+ }
1843
+ } else {
1844
+ // Pre-flight a declared size, then buffer through the counting guard that
1845
+ // hard-caps a body whose size is omitted or understated. Buffering
1846
+ // happens BEFORE the transaction so a slow upload holds no row lock.
1847
+ if (
1848
+ input.declaredBytes !== undefined &&
1849
+ input.declaredBytes > maxUploadBytes
1850
+ ) {
1851
+ throw new PayloadTooLargeError({
1852
+ maxUploadBytes,
1853
+ backendId: this.describe().id,
1854
+ uploadBytes: input.declaredBytes
1855
+ })
1856
+ }
1857
+ bytes = await bufferStreamCapped({
1858
+ stream: input.stream,
1859
+ maxUploadBytes,
1860
+ backendId: this.describe().id
1861
+ })
1862
+ }
1863
+
1864
+ return this._withTransaction(async client => {
1865
+ // Parent Resource must exist (and not be a tombstone). `FOR SHARE`
1866
+ // conflicts with the `FOR UPDATE` a concurrent `deleteResource` takes, so
1867
+ // the two serialize on the parent row -- the parent cannot be deleted
1868
+ // between this check and the chunk write.
1869
+ const { rows: parentRows } = await client.query<{ deleted: boolean }>(
1870
+ `SELECT deleted FROM resources
1871
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1872
+ FOR SHARE`,
1873
+ [spaceId, collectionId, resourceId]
1874
+ )
1875
+ const parent = parentRows[0]
1876
+ if (!parent || parent.deleted) {
1877
+ throw new ResourceNotFoundError({ requestName: 'Write Chunk' })
1878
+ }
1879
+
1880
+ // Lock the chunk row (if any) and read its current version/size, so the
1881
+ // precondition, the monotonic bump, and the usage delta are all atomic
1882
+ // with the write.
1883
+ const { rows } = await client.query<{ version: number; size: string }>(
1884
+ `SELECT version, size FROM chunks
1885
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1886
+ AND chunk_index = $4
1887
+ FOR UPDATE`,
1888
+ [spaceId, collectionId, resourceId, chunkIndex]
1889
+ )
1890
+ let prior = rows[0]
1891
+ const chunkLabel = `${resourceId}/chunks/${chunkIndex}`
1892
+ if (prior === undefined) {
1893
+ // The lock-nothing case: serialize with any concurrent creator of the
1894
+ // same chunk and re-read, so the usage delta below reflects the row it
1895
+ // committed (see `_lockSameKeyCreate`).
1896
+ await this._lockSameKeyCreate({
1897
+ client,
1898
+ spaceId,
1899
+ rowKey: `${collectionId}/${chunkLabel}`
1900
+ })
1901
+ const { rows: reread } = await client.query<{
1902
+ version: number
1903
+ size: string
1904
+ }>(
1905
+ `SELECT version, size FROM chunks
1906
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1907
+ AND chunk_index = $4
1908
+ FOR UPDATE`,
1909
+ [spaceId, collectionId, resourceId, chunkIndex]
1910
+ )
1911
+ prior = reread[0]
1912
+ }
1913
+ const exists = prior !== undefined
1914
+ if (ifMatch !== undefined || ifNoneMatch) {
1915
+ assertWritePrecondition({
1916
+ resourceId: chunkLabel,
1917
+ exists,
1918
+ currentVersion: prior?.version ?? 0,
1919
+ ifMatch,
1920
+ ifNoneMatch
1921
+ })
1922
+ }
1923
+
1924
+ const version = (prior?.version ?? 0) + 1
1925
+ const priorSize = exists ? Number(prior?.size ?? 0) : 0
1926
+ const delta = bytes.length - priorSize
1927
+ if (delta !== 0) {
1928
+ await this._applyUsageDelta({ client, spaceId, delta })
1929
+ }
1930
+
1931
+ const values = [
1932
+ spaceId,
1933
+ collectionId,
1934
+ resourceId,
1935
+ chunkIndex,
1936
+ input.contentType,
1937
+ bytes,
1938
+ bytes.length,
1939
+ version
1940
+ ]
1941
+ const insertSql = `
1942
+ INSERT INTO chunks (
1943
+ space_id, collection_id, resource_id, chunk_index,
1944
+ content_type, bytes, size, version
1945
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
1946
+ // Create-if-absent atomicity mirrors `writeResource`: concurrent
1947
+ // creators through this method are serialized by `_lockSameKeyCreate`
1948
+ // above, and against a writer that does not take that lock
1949
+ // (`importSpace`'s plain INSERTs) the plain INSERT keeps the primary key
1950
+ // as the arbiter -- a racing creator's unique violation maps to the 412
1951
+ // the precondition would have thrown.
1952
+ if (ifNoneMatch && prior === undefined) {
1953
+ try {
1954
+ await client.query(insertSql, values)
1955
+ } catch (err) {
1956
+ if ((err as { code?: string }).code === '23505') {
1957
+ throw new PreconditionFailedError({
1958
+ detail: `Chunk '${chunkLabel}' already exists (If-None-Match: *).`
1959
+ })
1960
+ }
1961
+ throw err
1962
+ }
1963
+ return { version }
1964
+ }
1965
+ // The conflict update derives `version` from the row (`chunks.version +
1966
+ // 1`), not from the pre-read, so a concurrent creator that slipped in
1967
+ // after our lock-nothing SELECT still advances the counter monotonically.
1968
+ // RETURNING reports the version that actually landed.
1969
+ const { rows: written } = await client.query<{ version: number }>(
1970
+ `${insertSql}
1971
+ ON CONFLICT (space_id, collection_id, resource_id, chunk_index)
1972
+ DO UPDATE SET
1973
+ content_type = EXCLUDED.content_type,
1974
+ bytes = EXCLUDED.bytes,
1975
+ size = EXCLUDED.size,
1976
+ version = chunks.version + 1
1977
+ RETURNING version`,
1978
+ values
1979
+ )
1980
+ return { version: written[0]!.version }
1981
+ })
1982
+ }
1983
+
1984
+ /**
1985
+ * Reads a chunk's bytes. Rejects with `ResourceNotFoundError` (404) when no
1986
+ * chunk is stored at that index.
1987
+ * @param options {object}
1988
+ * @param options.spaceId {string}
1989
+ * @param options.collectionId {string}
1990
+ * @param options.resourceId {string}
1991
+ * @param options.chunkIndex {number}
1992
+ * @returns {Promise<ResourceResult>}
1993
+ */
1994
+ async getChunk({
1995
+ spaceId,
1996
+ collectionId,
1997
+ resourceId,
1998
+ chunkIndex
1999
+ }: {
2000
+ spaceId: string
2001
+ collectionId: string
2002
+ resourceId: string
2003
+ chunkIndex: number
2004
+ }): Promise<ResourceResult> {
2005
+ const { rows } = await this._pool.query<{
2006
+ content_type: string
2007
+ bytes: Buffer
2008
+ version: number
2009
+ }>(
2010
+ `SELECT content_type, bytes, version FROM chunks
2011
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2012
+ AND chunk_index = $4`,
2013
+ [spaceId, collectionId, resourceId, chunkIndex]
2014
+ )
2015
+ const row = rows[0]
2016
+ if (!row) {
2017
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
2018
+ }
2019
+ return {
2020
+ resourceStream: Readable.from(row.bytes),
2021
+ storedResourceType: row.content_type,
2022
+ version: row.version
2023
+ }
2024
+ }
2025
+
2026
+ /**
2027
+ * Reads a chunk's stored content-type / size / version (the HEAD payload
2028
+ * headers). Resolves `undefined` when the chunk is absent.
2029
+ * @param options {object}
2030
+ * @param options.spaceId {string}
2031
+ * @param options.collectionId {string}
2032
+ * @param options.resourceId {string}
2033
+ * @param options.chunkIndex {number}
2034
+ * @returns {Promise<{ contentType: string, size: number, version?: number }
2035
+ * | undefined>}
2036
+ */
2037
+ async getChunkMetadata({
2038
+ spaceId,
2039
+ collectionId,
2040
+ resourceId,
2041
+ chunkIndex
2042
+ }: {
2043
+ spaceId: string
2044
+ collectionId: string
2045
+ resourceId: string
2046
+ chunkIndex: number
2047
+ }): Promise<
2048
+ { contentType: string; size: number; version?: number } | undefined
2049
+ > {
2050
+ const { rows } = await this._pool.query<{
2051
+ content_type: string
2052
+ size: string
2053
+ version: number
2054
+ }>(
2055
+ `SELECT content_type, size, version FROM chunks
2056
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2057
+ AND chunk_index = $4`,
2058
+ [spaceId, collectionId, resourceId, chunkIndex]
2059
+ )
2060
+ const row = rows[0]
2061
+ if (!row) {
2062
+ return undefined
2063
+ }
2064
+ return {
2065
+ contentType: row.content_type,
2066
+ size: Number(row.size),
2067
+ version: row.version
2068
+ }
2069
+ }
2070
+
2071
+ /**
2072
+ * Deletes one chunk as one transaction (the chunk row is locked, its
2073
+ * `ifMatch` precondition evaluated atomically, and its bytes returned to the
2074
+ * quota counter). Resolves `true` when a chunk was removed, `false` when none
2075
+ * was stored at that index (the handler 404s on `false` -- chunk deletes are
2076
+ * not silently idempotent, unlike `deleteResource`).
2077
+ * @param options {object}
2078
+ * @param options.spaceId {string}
2079
+ * @param options.collectionId {string}
2080
+ * @param options.resourceId {string}
2081
+ * @param options.chunkIndex {number}
2082
+ * @param [options.ifMatch] {string}
2083
+ * @returns {Promise<boolean>}
2084
+ */
2085
+ async deleteChunk({
2086
+ spaceId,
2087
+ collectionId,
2088
+ resourceId,
2089
+ chunkIndex,
2090
+ ifMatch
2091
+ }: {
2092
+ spaceId: string
2093
+ collectionId: string
2094
+ resourceId: string
2095
+ chunkIndex: number
2096
+ ifMatch?: string
2097
+ }): Promise<boolean> {
2098
+ return this._withTransaction(async client => {
2099
+ const { rows } = await client.query<{ version: number; size: string }>(
2100
+ `SELECT version, size FROM chunks
2101
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2102
+ AND chunk_index = $4
2103
+ FOR UPDATE`,
2104
+ [spaceId, collectionId, resourceId, chunkIndex]
2105
+ )
2106
+ const prior = rows[0]
2107
+ if (prior === undefined) {
2108
+ return false
2109
+ }
2110
+ if (ifMatch !== undefined) {
2111
+ assertWritePrecondition({
2112
+ resourceId: `${resourceId}/chunks/${chunkIndex}`,
2113
+ exists: true,
2114
+ currentVersion: prior.version,
2115
+ ifMatch
2116
+ })
2117
+ }
2118
+ await client.query(
2119
+ `DELETE FROM chunks
2120
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2121
+ AND chunk_index = $4`,
2122
+ [spaceId, collectionId, resourceId, chunkIndex]
2123
+ )
2124
+ const freedBytes = Number(prior.size)
2125
+ if (freedBytes > 0) {
2126
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
2127
+ }
2128
+ return true
2129
+ })
2130
+ }
2131
+
2132
+ /**
2133
+ * Lists a Resource's stored chunks in ascending `chunk_index` order -- the
2134
+ * discovery/reassembly listing. The opaque `bytes` column is deliberately not
2135
+ * selected. An empty listing (Resource with no chunks, or an absent Resource)
2136
+ * resolves `{ count: 0, chunks: [] }`.
2137
+ * @param options {object}
2138
+ * @param options.spaceId {string}
2139
+ * @param options.collectionId {string}
2140
+ * @param options.resourceId {string}
2141
+ * @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
2142
+ * number, contentType: string, version?: number }> }>}
2143
+ */
2144
+ async listChunks({
2145
+ spaceId,
2146
+ collectionId,
2147
+ resourceId
2148
+ }: {
2149
+ spaceId: string
2150
+ collectionId: string
2151
+ resourceId: string
2152
+ }): Promise<{
2153
+ count: number
2154
+ chunks: Array<{
2155
+ index: number
2156
+ size: number
2157
+ contentType: string
2158
+ version?: number
2159
+ }>
2160
+ }> {
2161
+ const { rows } = await this._pool.query<{
2162
+ chunk_index: number
2163
+ size: string
2164
+ content_type: string
2165
+ version: number
2166
+ }>(
2167
+ `SELECT chunk_index, size, content_type, version FROM chunks
2168
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2169
+ ORDER BY chunk_index`,
2170
+ [spaceId, collectionId, resourceId]
2171
+ )
2172
+ return {
2173
+ count: rows.length,
2174
+ chunks: rows.map(row => ({
2175
+ index: row.chunk_index,
2176
+ size: Number(row.size),
2177
+ contentType: row.content_type,
2178
+ version: row.version
2179
+ }))
2180
+ }
2181
+ }
2182
+
1674
2183
  /**
1675
2184
  * Replication change feed (the `changes` query profile): one indexed keyset
1676
2185
  * query over `(updatedAt, resourceId)`, tombstones included, JSON documents
@@ -2435,7 +2944,8 @@ export class PostgresBackend implements StorageBackend {
2435
2944
  { rows: policyRows },
2436
2945
  { rows: collectionRows },
2437
2946
  { rows: resourceRows },
2438
- { rows: revocationRows }
2947
+ { rows: revocationRows },
2948
+ { rows: chunkRows }
2439
2949
  ] = await Promise.all([
2440
2950
  this._pool.query<{
2441
2951
  collection_id: string
@@ -2477,6 +2987,20 @@ export class PostgresBackend implements StorageBackend {
2477
2987
  `SELECT delegator, capability_id, record FROM space_revocations
2478
2988
  WHERE space_id = $1`,
2479
2989
  [spaceId]
2990
+ ),
2991
+ // Chunk metadata only -- bytes are fetched one chunk at a time while
2992
+ // packing, so an export never holds a chunked Resource whole in memory.
2993
+ this._pool.query<{
2994
+ collection_id: string
2995
+ resource_id: string
2996
+ chunk_index: number
2997
+ content_type: string
2998
+ version: number
2999
+ }>(
3000
+ `SELECT collection_id, resource_id, chunk_index, content_type, version
3001
+ FROM chunks WHERE space_id = $1
3002
+ ORDER BY collection_id, resource_id, chunk_index`,
3003
+ [spaceId]
2480
3004
  )
2481
3005
  ])
2482
3006
 
@@ -2487,11 +3011,26 @@ export class PostgresBackend implements StorageBackend {
2487
3011
  row => row.collection_id === '' && row.resource_id === ''
2488
3012
  )?.policy
2489
3013
 
2490
- // A file entry carries its bytes inline (the small JSON dot-files) or a
2491
- // lazy reference to a resource representation, resolved at pack time.
3014
+ // A file entry carries its bytes inline (the small JSON dot-files), a lazy
3015
+ // reference to a resource representation, or a chunk directory (the
3016
+ // `chunked-streams` feature) whose files are likewise inline (the per-chunk
3017
+ // `.meta.<index>.json` sidecar) or lazy (the chunk `r.<index>...`
3018
+ // representation, resolved at pack time). `name` is the entry's sort key
3019
+ // within its dir; a chunk directory sorts by its `.chunks.<encId>` dir name.
3020
+ type ChunkFile =
3021
+ | { name: string; bytes: Buffer }
3022
+ | {
3023
+ name: string
3024
+ chunk: {
3025
+ collectionId: string
3026
+ resourceId: string
3027
+ chunkIndex: number
3028
+ }
3029
+ }
2492
3030
  type FileEntry =
2493
3031
  | { name: string; bytes: Buffer }
2494
3032
  | { name: string; resource: { collectionId: string; resourceId: string } }
3033
+ | { name: string; chunkDir: ChunkFile[] }
2495
3034
  // Space-level dot-files are always small JSON, carried inline.
2496
3035
  const spaceFiles: Array<{ name: string; bytes: Buffer }> = [
2497
3036
  {
@@ -2564,6 +3103,48 @@ export class PostgresBackend implements StorageBackend {
2564
3103
  })
2565
3104
  }
2566
3105
  }
3106
+ // Chunks (the `chunked-streams` feature): each chunked Resource contributes
3107
+ // one `.chunks.<encResourceId>/` subdirectory in its Collection dir, in the
3108
+ // exact filesystem-backend layout so an archive imports into either backend.
3109
+ // A chunk is stored there as a Resource keyed by its stringified index: an
3110
+ // `r.<index>.<encContentType>.<ext>` representation file (`fileNameFor`)
3111
+ // plus a `.meta.<index>.json` version sidecar. Files within a chunk dir are
3112
+ // sorted by name (the filesystem's readdir sort). Rows arrive ordered by
3113
+ // `(collection, resource, index)`.
3114
+ const chunkDirsByResource = new Map<string, ChunkFile[]>()
3115
+ for (const row of chunkRows) {
3116
+ const dirKey = `${row.collection_id}/${row.resource_id}`
3117
+ let chunkFiles = chunkDirsByResource.get(dirKey)
3118
+ if (!chunkFiles) {
3119
+ chunkFiles = []
3120
+ chunkDirsByResource.set(dirKey, chunkFiles)
3121
+ filesFor(row.collection_id).push({
3122
+ name: chunkDirName(row.resource_id),
3123
+ chunkDir: chunkFiles
3124
+ })
3125
+ }
3126
+ const chunkId = String(row.chunk_index)
3127
+ chunkFiles.push({
3128
+ name: fileNameFor({
3129
+ resourceId: chunkId,
3130
+ contentType: row.content_type
3131
+ }),
3132
+ chunk: {
3133
+ collectionId: row.collection_id,
3134
+ resourceId: row.resource_id,
3135
+ chunkIndex: row.chunk_index
3136
+ }
3137
+ })
3138
+ chunkFiles.push({
3139
+ name: `.meta.${chunkId}.json`,
3140
+ bytes: Buffer.from(
3141
+ JSON.stringify({ version: row.version } satisfies ChunkSidecar)
3142
+ )
3143
+ })
3144
+ }
3145
+ for (const chunkFiles of chunkDirsByResource.values()) {
3146
+ chunkFiles.sort((left, right) => left.name.localeCompare(right.name))
3147
+ }
2567
3148
 
2568
3149
  // Top-level order: space-level files and collection dirs interleaved,
2569
3150
  // sorted by name -- the same order the filesystem's readdir+sort yields.
@@ -2597,7 +3178,17 @@ export class PostgresBackend implements StorageBackend {
2597
3178
  spaceId,
2598
3179
  entries: topLevel.map(entry =>
2599
3180
  entry.kind === 'collection'
2600
- ? { name: entry.name, files: entry.files.map(file => file.name) }
3181
+ ? {
3182
+ name: entry.name,
3183
+ // A chunk directory expands to its `.chunks.<encId>/<file>`
3184
+ // relative paths (mirroring the pack order), so the manifest
3185
+ // matches the filesystem backend's for the same Space.
3186
+ files: entry.files.flatMap(file =>
3187
+ 'chunkDir' in file
3188
+ ? file.chunkDir.map(child => `${file.name}/${child.name}`)
3189
+ : [file.name]
3190
+ )
3191
+ }
2601
3192
  : { name: entry.name }
2602
3193
  ),
2603
3194
  revocationFiles: revocationFiles.map(file => file.name)
@@ -2615,6 +3206,25 @@ export class PostgresBackend implements StorageBackend {
2615
3206
  if (entry.kind === 'collection') {
2616
3207
  pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime })
2617
3208
  for (const file of entry.files) {
3209
+ if ('chunkDir' in file) {
3210
+ // A chunked Resource's `.chunks.<encId>/` subdirectory: emit the
3211
+ // directory entry, then each file -- an inline `.meta.<index>.json`
3212
+ // sidecar, or a chunk representation whose bytes are fetched one at
3213
+ // a time.
3214
+ const dirTarget = `${entryTarget}/${file.name}`
3215
+ pack.entry({ name: `${dirTarget}/`, type: 'directory', mtime })
3216
+ for (const chunkFile of file.chunkDir) {
3217
+ const bytes =
3218
+ 'bytes' in chunkFile
3219
+ ? chunkFile.bytes
3220
+ : await this._chunkContent({ spaceId, ...chunkFile.chunk })
3221
+ pack.entry(
3222
+ { name: `${dirTarget}/${chunkFile.name}`, mtime },
3223
+ bytes
3224
+ )
3225
+ }
3226
+ continue
3227
+ }
2618
3228
  const bytes =
2619
3229
  'bytes' in file
2620
3230
  ? file.bytes
@@ -2663,6 +3273,38 @@ export class PostgresBackend implements StorageBackend {
2663
3273
  return rows[0]?.content ?? Buffer.alloc(0)
2664
3274
  }
2665
3275
 
3276
+ /**
3277
+ * Fetches one chunk's bytes for the export pack loop (the `chunked-streams`
3278
+ * feature). A chunk removed between the metadata pass and this read yields an
3279
+ * empty body rather than failing the whole archive, matching
3280
+ * `_resourceContent`.
3281
+ * @param options {object}
3282
+ * @param options.spaceId {string}
3283
+ * @param options.collectionId {string}
3284
+ * @param options.resourceId {string}
3285
+ * @param options.chunkIndex {number}
3286
+ * @returns {Promise<Buffer>}
3287
+ */
3288
+ private async _chunkContent({
3289
+ spaceId,
3290
+ collectionId,
3291
+ resourceId,
3292
+ chunkIndex
3293
+ }: {
3294
+ spaceId: string
3295
+ collectionId: string
3296
+ resourceId: string
3297
+ chunkIndex: number
3298
+ }): Promise<Buffer> {
3299
+ const { rows } = await this._pool.query<{ bytes: Buffer | null }>(
3300
+ `SELECT bytes FROM chunks
3301
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
3302
+ AND chunk_index = $4`,
3303
+ [spaceId, collectionId, resourceId, chunkIndex]
3304
+ )
3305
+ return rows[0]?.bytes ?? Buffer.alloc(0)
3306
+ }
3307
+
2666
3308
  /**
2667
3309
  * Merges a WAS space-export tarball into an existing Space with the same
2668
3310
  * three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
@@ -2685,6 +3327,10 @@ export class PostgresBackend implements StorageBackend {
2685
3327
  }): Promise<ImportStats> {
2686
3328
  const entries = await extractTarEntries(tarStream)
2687
3329
  const { spacePolicy, collections, revocations } = buildImportPlan(entries)
3330
+ // Chunk entries (the `chunked-streams` feature) live in per-Resource
3331
+ // `.chunks.<encId>/` subdirectories, which `buildImportPlan` skips (it
3332
+ // ignores nested files); parse them straight off the raw tar entries.
3333
+ const chunkEntries = this._chunkEntriesFromArchive(entries)
2688
3334
  const {
2689
3335
  capacityBytes,
2690
3336
  maxUploadBytes,
@@ -2774,6 +3420,19 @@ export class PostgresBackend implements StorageBackend {
2774
3420
  incomingBytes += body.length
2775
3421
  }
2776
3422
  }
3423
+ // Chunks (the `chunked-streams` feature) are opaque bytes -- no
3424
+ // encryption-conformance check applies -- but they count the same per-body
3425
+ // 413 and the (conservative) capacity pre-flight as Resource bodies.
3426
+ for (const chunk of chunkEntries) {
3427
+ if (chunk.body.length > maxUploadBytes) {
3428
+ throw new PayloadTooLargeError({
3429
+ maxUploadBytes,
3430
+ backendId: this.describe().id,
3431
+ uploadBytes: chunk.body.length
3432
+ })
3433
+ }
3434
+ incomingBytes += chunk.body.length
3435
+ }
2777
3436
  if (
2778
3437
  capacityBytes !== undefined &&
2779
3438
  currentUsage + incomingBytes > capacityBytes
@@ -2966,13 +3625,63 @@ export class PostgresBackend implements StorageBackend {
2966
3625
  }
2967
3626
  }
2968
3627
 
2969
- if (createdBytes > 0) {
3628
+ // Chunks (the `chunked-streams` feature): restore each archived chunk
3629
+ // skip-not-overwrite, after the Resource apply loop so a chunk's parent
3630
+ // Resource row already exists in this transaction (the foreign key
3631
+ // requires it). An orphan chunk -- one whose parent is absent or a
3632
+ // tombstone -- is skipped rather than resurrected. Existing chunk rows are
3633
+ // left untouched.
3634
+ let createdChunkBytes = 0
3635
+ for (const chunk of chunkEntries) {
3636
+ const { rows: parentRows } = await client.query<{ deleted: boolean }>(
3637
+ `SELECT deleted FROM resources
3638
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
3639
+ [spaceId, chunk.collectionId, chunk.resourceId]
3640
+ )
3641
+ const parent = parentRows[0]
3642
+ if (!parent || parent.deleted) {
3643
+ continue
3644
+ }
3645
+ const { rows: existingChunkRows } = await client.query(
3646
+ `SELECT 1 FROM chunks
3647
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
3648
+ AND chunk_index = $4`,
3649
+ [spaceId, chunk.collectionId, chunk.resourceId, chunk.chunkIndex]
3650
+ )
3651
+ if (existingChunkRows.length > 0) {
3652
+ continue
3653
+ }
3654
+ // The chunk's `version` comes from its archived `.meta.<index>.json`
3655
+ // sidecar; an archive without one (or a chunk written before sidecars)
3656
+ // starts at version 1, the same fresh-write default as a Resource
3657
+ // restored without a sidecar.
3658
+ await client.query(
3659
+ `INSERT INTO chunks (
3660
+ space_id, collection_id, resource_id, chunk_index,
3661
+ content_type, bytes, size, version
3662
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
3663
+ [
3664
+ spaceId,
3665
+ chunk.collectionId,
3666
+ chunk.resourceId,
3667
+ chunk.chunkIndex,
3668
+ chunk.contentType,
3669
+ chunk.body,
3670
+ chunk.body.length,
3671
+ chunk.version ?? 1
3672
+ ]
3673
+ )
3674
+ createdChunkBytes += chunk.body.length
3675
+ }
3676
+
3677
+ const createdTotalBytes = createdBytes + createdChunkBytes
3678
+ if (createdTotalBytes > 0) {
2970
3679
  // The pre-flight was conservative (it counted skips too), so the
2971
3680
  // actual created total always fits; apply it unguarded.
2972
3681
  await this._applyUsageDelta({
2973
3682
  client,
2974
3683
  spaceId,
2975
- delta: createdBytes
3684
+ delta: createdTotalBytes
2976
3685
  })
2977
3686
  }
2978
3687
 
@@ -3070,4 +3779,141 @@ export class PostgresBackend implements StorageBackend {
3070
3779
  ]
3071
3780
  )
3072
3781
  }
3782
+
3783
+ /**
3784
+ * Parses a Space-export archive's chunk entries (the `chunked-streams`
3785
+ * feature) out of the raw tar entry map, in the filesystem backend's on-disk
3786
+ * layout so archives round-trip between the two backends. Chunk files live at
3787
+ * `space/<sourceSpaceId>/<collectionId>/.chunks.<encResourceId>/<file>` --
3788
+ * where a chunk is a Resource keyed by its stringified index: an
3789
+ * `r.<index>.<encContentType>.<ext>` representation paired with an optional
3790
+ * `.meta.<index>.json` version sidecar. `buildImportPlan` skips these (it
3791
+ * ignores nested Collection-dir files), so they are parsed here. Ids parsed
3792
+ * from the archive are validated (the path-traversal guard); a file whose
3793
+ * raw index segment is not canonical (`parseChunkIndexSegment`) is ignored,
3794
+ * and a sidecar with no paired representation is dropped (a chunk keeps no
3795
+ * tombstone).
3796
+ * @param entries {Map<string, TarEntry>}
3797
+ * @returns {Array<{ collectionId: string, resourceId: string, chunkIndex:
3798
+ * number, contentType: string, body: Buffer, version?: number }>}
3799
+ */
3800
+ private _chunkEntriesFromArchive(entries: Map<string, TarEntry>): Array<{
3801
+ collectionId: string
3802
+ resourceId: string
3803
+ chunkIndex: number
3804
+ contentType: string
3805
+ body: Buffer
3806
+ version?: number
3807
+ }> {
3808
+ // Accumulate the representation and the sidecar of each chunk under one
3809
+ // key, then emit only the chunks that carry a representation.
3810
+ const staged = new Map<
3811
+ string,
3812
+ {
3813
+ collectionId: string
3814
+ resourceId: string
3815
+ chunkIndex: number
3816
+ contentType?: string
3817
+ body?: Buffer
3818
+ version?: number
3819
+ }
3820
+ >()
3821
+ const pattern = /^space\/[^/]+\/([^/]+)\/(\.chunks\.[^/]+)\/([^/]+)$/
3822
+ for (const [entryName, entry] of entries) {
3823
+ if (entry.type !== 'file' || !entry.body) {
3824
+ continue
3825
+ }
3826
+ const match = entryName.match(pattern)
3827
+ if (!match) {
3828
+ continue
3829
+ }
3830
+ const collectionId = match[1]!
3831
+ const resourceId = parseChunkDirName(match[2]!)
3832
+ const fileName = match[3]!
3833
+ if (resourceId === undefined) {
3834
+ continue
3835
+ }
3836
+ // Reject a path-traversal / non-URL-safe id parsed from the archive
3837
+ // before it keys a destination row.
3838
+ assertValidId(collectionId, {
3839
+ kind: 'collection',
3840
+ requestName: 'Import Space'
3841
+ })
3842
+ assertValidId(resourceId, {
3843
+ kind: 'resource',
3844
+ requestName: 'Import Space'
3845
+ })
3846
+
3847
+ // A chunk's index is the "resource id" of its file within the chunk dir:
3848
+ // `r.<index>.<encType>.<ext>` for the representation, `.meta.<index>.json`
3849
+ // for the version sidecar. The RAW index segment must pass the same
3850
+ // canonical predicate as the live route (`parseChunkIndexSegment`), so a
3851
+ // non-canonical spelling (`r.01.*`, `r.%31.*`) is dropped rather than
3852
+ // coerced onto -- and colliding with -- the canonical chunk's row.
3853
+ let chunkIdSegment: string | undefined
3854
+ let representation: { contentType: string; body: Buffer } | undefined
3855
+ let sidecarVersion: number | undefined
3856
+ const metaId = metaSidecarFileId(fileName)
3857
+ if (metaId !== undefined) {
3858
+ chunkIdSegment = metaId
3859
+ try {
3860
+ const sidecar = JSON.parse(
3861
+ entry.body.toString('utf8')
3862
+ ) as ChunkSidecar
3863
+ sidecarVersion = sidecar.version
3864
+ } catch {
3865
+ sidecarVersion = undefined
3866
+ }
3867
+ } else if (fileName.startsWith('r.')) {
3868
+ const { contentType } = parseResourceFileName(fileName)
3869
+ chunkIdSegment = fileName.split('.')[1]
3870
+ representation = { contentType, body: entry.body }
3871
+ } else {
3872
+ continue
3873
+ }
3874
+
3875
+ const chunkIndex =
3876
+ chunkIdSegment === undefined
3877
+ ? undefined
3878
+ : parseChunkIndexSegment(chunkIdSegment)
3879
+ if (chunkIndex === undefined) {
3880
+ continue
3881
+ }
3882
+
3883
+ const key = `${collectionId}/${resourceId}/${chunkIndex}`
3884
+ const slot = staged.get(key) ?? { collectionId, resourceId, chunkIndex }
3885
+ if (representation !== undefined) {
3886
+ slot.contentType = representation.contentType
3887
+ slot.body = representation.body
3888
+ }
3889
+ if (sidecarVersion !== undefined) {
3890
+ slot.version = sidecarVersion
3891
+ }
3892
+ staged.set(key, slot)
3893
+ }
3894
+
3895
+ const parsed: Array<{
3896
+ collectionId: string
3897
+ resourceId: string
3898
+ chunkIndex: number
3899
+ contentType: string
3900
+ body: Buffer
3901
+ version?: number
3902
+ }> = []
3903
+ for (const slot of staged.values()) {
3904
+ if (slot.body === undefined) {
3905
+ // A sidecar with no paired representation is not a valid chunk.
3906
+ continue
3907
+ }
3908
+ parsed.push({
3909
+ collectionId: slot.collectionId,
3910
+ resourceId: slot.resourceId,
3911
+ chunkIndex: slot.chunkIndex,
3912
+ contentType: slot.contentType ?? 'application/octet-stream',
3913
+ body: slot.body,
3914
+ version: slot.version
3915
+ })
3916
+ }
3917
+ return parsed
3918
+ }
3073
3919
  }