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
@@ -30,14 +30,15 @@ import * as tar from 'tar-stream';
30
30
  import YAML from 'yaml';
31
31
  import { StorageError, ResourceNotFoundError, SpaceNotFoundError, QuotaExceededError, CountQuotaExceededError, PayloadTooLargeError, PreconditionFailedError, KeystoreStateConflictError, KeyIdConflictError, DuplicateRevocationError } from '../errors.js';
32
32
  import { applyMigrations } from './postgresSchema.js';
33
- import { extractTarEntries, buildImportPlan } from '../lib/importTar.js';
33
+ import { extractTarEntries, buildImportPlan, metaSidecarFileId } from '../lib/importTar.js';
34
34
  import { collectionPath, resourcePath } from '../lib/paths.js';
35
- import { fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js';
35
+ import { fileNameFor, parseResourceFileName, chunkDirName, parseChunkDirName, parseChunkIndexSegment } from '../lib/resourceFileName.js';
36
+ import { assertValidId } from '../lib/validateId.js';
36
37
  import { sanitizeBackendRecord } from '../lib/backends.js';
37
38
  import { backendUsageFields } from '../lib/backendUsage.js';
38
39
  import { assertEncryptedWriteConforms } from '../lib/encryption.js';
39
40
  import { encodeCursor, decodeCursor } from '../lib/cursor.js';
40
- import { buildExportManifest } from '../lib/exportManifest.js';
41
+ import { buildExportManifest, EXPORT_ENTRY_MTIME } from '../lib/exportManifest.js';
41
42
  import { revocationFileName } from '../lib/revocations.js';
42
43
  import { isJson } from '../lib/isJson.js';
43
44
  import { normalizeDescriptionWrite } from '../lib/collectionDescription.js';
@@ -277,11 +278,14 @@ export class PostgresBackend {
277
278
  // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
278
279
  // keys on the `encryption` marker, a client-declared `epoch` stamp on
279
280
  // Resources, and conditional (`If-Match`) Collection Description writes.
281
+ // `chunked-streams`: chunk addressing at `/{resourceId}/chunks/{n}`, opaque
282
+ // per-chunk raw-bytes storage in the `chunks` table.
280
283
  features: [
281
284
  'conditional-writes',
282
285
  'changes-query',
283
286
  'blinded-index-query',
284
- 'key-epochs'
287
+ 'key-epochs',
288
+ 'chunked-streams'
285
289
  ]
286
290
  };
287
291
  }
@@ -340,6 +344,30 @@ export class PostgresBackend {
340
344
  });
341
345
  }
342
346
  }
347
+ /**
348
+ * Serializes concurrent CREATORS of one not-yet-existing row (a Resource or
349
+ * a chunk) on a transaction-scoped advisory lock keyed by the row's
350
+ * identity. Under READ COMMITTED a `SELECT ... FOR UPDATE` on an absent row
351
+ * locks nothing (no gap locks), so two concurrent creators would both read
352
+ * "no prior row" and both apply their full byte size as the usage delta --
353
+ * and while the `ON CONFLICT` arm recomputes `version` from the conflicting
354
+ * row, it cannot retroactively fix the loser's delta, leaving `usage_bytes`
355
+ * inflated by one write's size. The caller takes this lock when its
356
+ * lock-nothing SELECT found no row, then RE-reads the row: the second
357
+ * creator blocks here until the first commits, and its re-read sees the
358
+ * committed row, so its precondition, version, and usage delta are computed
359
+ * from accurate state. Held to commit (advisory xact lock). The `create:`
360
+ * prefix keeps this key domain distinct from the unique-blinded-term
361
+ * advisory lock, which hashes the bare `(spaceId, collectionId)`.
362
+ * @param options {object}
363
+ * @param options.client {pg.PoolClient}
364
+ * @param options.spaceId {string}
365
+ * @param options.rowKey {string} the row's identity within the Space
366
+ * @returns {Promise<void>}
367
+ */
368
+ async _lockSameKeyCreate({ client, spaceId, rowKey }) {
369
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [spaceId, `create:${rowKey}`]);
370
+ }
343
371
  // Quotas
344
372
  /**
345
373
  * Reports the Space's usage from the transactional counter (no measurement
@@ -356,9 +384,16 @@ export class PostgresBackend {
356
384
  const usageBytes = rows[0] ? Number(rows[0].usage_bytes) : 0;
357
385
  let usageByCollection;
358
386
  if (includeCollections) {
359
- const { rows: collectionRows } = await this._pool.query(`SELECT collection_id, COALESCE(SUM(size_bytes), 0) AS usage
360
- FROM resources
361
- WHERE space_id = $1
387
+ // Per-Collection usage sums both Resource content bytes and chunk bytes
388
+ // (the `chunked-streams` feature) so the breakdown agrees with the
389
+ // Space total in the transactional counter.
390
+ const { rows: collectionRows } = await this._pool.query(`SELECT collection_id, COALESCE(SUM(bytes), 0) AS usage FROM (
391
+ SELECT collection_id, size_bytes AS bytes FROM resources
392
+ WHERE space_id = $1
393
+ UNION ALL
394
+ SELECT collection_id, size AS bytes FROM chunks
395
+ WHERE space_id = $1
396
+ ) usage_rows
362
397
  GROUP BY collection_id
363
398
  ORDER BY collection_id`, [spaceId]);
364
399
  usageByCollection = collectionRows.map(row => ({
@@ -387,6 +422,8 @@ export class PostgresBackend {
387
422
  (SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
388
423
  AS space_total,
389
424
  (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
425
+ WHERE space_id = $1 AND collection_id = $2)
426
+ + (SELECT COALESCE(SUM(size), 0) FROM chunks
390
427
  WHERE space_id = $1 AND collection_id = $2) AS collection_total`, [spaceId, collectionId]);
391
428
  const spaceTotalBytes = Number(rows[0]?.space_total ?? 0);
392
429
  const usageBytes = Number(rows[0]?.collection_total ?? 0);
@@ -698,8 +735,14 @@ export class PostgresBackend {
698
735
  */
699
736
  async deleteCollection({ spaceId, collectionId }) {
700
737
  await this._withTransaction(async (client) => {
701
- const { rows } = await client.query(`SELECT COALESCE(SUM(size_bytes), 0) AS total FROM resources
702
- WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
738
+ // The Collection's freed bytes are its Resource content plus its chunk
739
+ // bytes (the `chunked-streams` feature); both cascade away with the
740
+ // Collection row, so both leave the quota counter.
741
+ const { rows } = await client.query(`SELECT
742
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
743
+ WHERE space_id = $1 AND collection_id = $2)
744
+ + (SELECT COALESCE(SUM(size), 0) FROM chunks
745
+ WHERE space_id = $1 AND collection_id = $2) AS total`, [spaceId, collectionId]);
703
746
  const freedBytes = Number(rows[0]?.total ?? 0);
704
747
  await client.query(`DELETE FROM collections WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
705
748
  // Collection- and Resource-level policies live under the Collection (the
@@ -887,7 +930,23 @@ export class PostgresBackend {
887
930
  FROM resources
888
931
  WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
889
932
  FOR UPDATE`, [spaceId, collectionId, resourceId]);
890
- const prior = rows[0];
933
+ let prior = rows[0];
934
+ if (prior === undefined) {
935
+ // The lock-nothing case: serialize with any concurrent creator of the
936
+ // same id and re-read, so `exists` / `priorSize` below reflect the row
937
+ // it committed (see `_lockSameKeyCreate` -- without this, both
938
+ // creators would count their full byte size as the usage delta).
939
+ await this._lockSameKeyCreate({
940
+ client,
941
+ spaceId,
942
+ rowKey: `${collectionId}/${resourceId}`
943
+ });
944
+ const { rows: reread } = await client.query(`SELECT version, size_bytes, deleted, created_at, created_by
945
+ FROM resources
946
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
947
+ FOR UPDATE`, [spaceId, collectionId, resourceId]);
948
+ prior = reread[0];
949
+ }
891
950
  const exists = prior !== undefined && !prior.deleted;
892
951
  if (ifMatch !== undefined || ifNoneMatch) {
893
952
  assertWritePrecondition({
@@ -914,7 +973,7 @@ export class PostgresBackend {
914
973
  }
915
974
  const now = new Date().toISOString();
916
975
  const version = (prior?.version ?? 0) + 1;
917
- const priorSize = exists ? Number(prior.size_bytes) : 0;
976
+ const priorSize = exists ? Number(prior?.size_bytes ?? 0) : 0;
918
977
  const delta = content.length - priorSize;
919
978
  if (delta !== 0) {
920
979
  await this._applyUsageDelta({ client, spaceId, delta });
@@ -924,10 +983,11 @@ export class PostgresBackend {
924
983
  // dropped both (the metadata went with the deleted Resource).
925
984
  //
926
985
  // Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
927
- // (a tombstone is a real row and stays lock-serialized), the SELECT FOR
928
- // UPDATE locked nothing -- READ COMMITTED has no gap locks -- so a
929
- // concurrent creator may have raced past the same precondition. A plain
930
- // INSERT (no ON CONFLICT) makes the primary key the arbiter: the loser's
986
+ // (a tombstone is a real row and stays lock-serialized), concurrent
987
+ // creators through this method are already serialized by
988
+ // `_lockSameKeyCreate` above -- but a writer that does not take that
989
+ // lock (`importSpace`'s plain INSERTs) can still race. A plain INSERT
990
+ // (no ON CONFLICT) keeps the primary key as the arbiter: the loser's
931
991
  // unique violation maps to the 412 the precondition would have thrown.
932
992
  // `createdBy` names the Resource's creator, not its last writer: taken
933
993
  // from this write's invoker only when there is no prior row at all
@@ -1062,7 +1122,19 @@ export class PostgresBackend {
1062
1122
  // no-op, keeping an existing tombstone's change-feed entry stable.
1063
1123
  return;
1064
1124
  }
1065
- const freedBytes = Number(prior.size_bytes);
1125
+ // A soft delete is an UPDATE, not a row removal, so the chunk foreign
1126
+ // key's ON DELETE CASCADE does not fire -- remove the Resource's chunks
1127
+ // (the `chunked-streams` feature) explicitly in this same transaction so
1128
+ // they never outlive their parent, and return their bytes to the quota
1129
+ // counter alongside the Resource's content bytes.
1130
+ const { rows: chunkRows } = await client.query(`SELECT COALESCE(SUM(size), 0) AS total FROM chunks
1131
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1132
+ const freedChunkBytes = Number(chunkRows[0]?.total ?? 0);
1133
+ if (freedChunkBytes > 0) {
1134
+ await client.query(`DELETE FROM chunks
1135
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1136
+ }
1137
+ const freedBytes = Number(prior.size_bytes) + freedChunkBytes;
1066
1138
  if (freedBytes > 0) {
1067
1139
  await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
1068
1140
  }
@@ -1168,6 +1240,276 @@ export class PostgresBackend {
1168
1240
  return { metaVersion };
1169
1241
  });
1170
1242
  }
1243
+ // Chunks (the `chunked-streams` feature)
1244
+ /**
1245
+ * Writes one chunk of a chunked Resource as one transaction: the parent
1246
+ * Resource must exist (checked atomically -- a `FOR SHARE` lock on it also
1247
+ * blocks a concurrent delete of the parent for the duration of the write, so
1248
+ * a chunk can never be orphaned by a racing `deleteResource`), the chunk row
1249
+ * is locked, its precondition evaluated, its monotonic `version` bumped, and
1250
+ * the transactional quota delta applied. The chunk body is stored opaquely as
1251
+ * a single `bytea`, the buffered-blob path (bounded by `maxUploadBytes`),
1252
+ * exactly like a binary Resource representation.
1253
+ * @param options {object}
1254
+ * @param options.spaceId {string}
1255
+ * @param options.collectionId {string}
1256
+ * @param options.resourceId {string}
1257
+ * @param options.chunkIndex {number} a non-negative safe integer
1258
+ * @param options.input {ResourceInput}
1259
+ * @param [options.ifMatch] {string}
1260
+ * @param [options.ifNoneMatch] {boolean}
1261
+ * @returns {Promise<{ version: number }>} the chunk's new version
1262
+ */
1263
+ async writeChunk({ spaceId, collectionId, resourceId, chunkIndex, input, ifMatch, ifNoneMatch }) {
1264
+ const { maxUploadBytes } = this;
1265
+ let bytes;
1266
+ if (input.kind === 'json') {
1267
+ bytes = Buffer.from(JSON.stringify(input.data));
1268
+ if (bytes.length > maxUploadBytes) {
1269
+ throw new PayloadTooLargeError({
1270
+ maxUploadBytes,
1271
+ backendId: this.describe().id,
1272
+ uploadBytes: bytes.length
1273
+ });
1274
+ }
1275
+ }
1276
+ else {
1277
+ // Pre-flight a declared size, then buffer through the counting guard that
1278
+ // hard-caps a body whose size is omitted or understated. Buffering
1279
+ // happens BEFORE the transaction so a slow upload holds no row lock.
1280
+ if (input.declaredBytes !== undefined &&
1281
+ input.declaredBytes > maxUploadBytes) {
1282
+ throw new PayloadTooLargeError({
1283
+ maxUploadBytes,
1284
+ backendId: this.describe().id,
1285
+ uploadBytes: input.declaredBytes
1286
+ });
1287
+ }
1288
+ bytes = await bufferStreamCapped({
1289
+ stream: input.stream,
1290
+ maxUploadBytes,
1291
+ backendId: this.describe().id
1292
+ });
1293
+ }
1294
+ return this._withTransaction(async (client) => {
1295
+ // Parent Resource must exist (and not be a tombstone). `FOR SHARE`
1296
+ // conflicts with the `FOR UPDATE` a concurrent `deleteResource` takes, so
1297
+ // the two serialize on the parent row -- the parent cannot be deleted
1298
+ // between this check and the chunk write.
1299
+ const { rows: parentRows } = await client.query(`SELECT deleted FROM resources
1300
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1301
+ FOR SHARE`, [spaceId, collectionId, resourceId]);
1302
+ const parent = parentRows[0];
1303
+ if (!parent || parent.deleted) {
1304
+ throw new ResourceNotFoundError({ requestName: 'Write Chunk' });
1305
+ }
1306
+ // Lock the chunk row (if any) and read its current version/size, so the
1307
+ // precondition, the monotonic bump, and the usage delta are all atomic
1308
+ // with the write.
1309
+ const { rows } = await client.query(`SELECT version, size FROM chunks
1310
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1311
+ AND chunk_index = $4
1312
+ FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
1313
+ let prior = rows[0];
1314
+ const chunkLabel = `${resourceId}/chunks/${chunkIndex}`;
1315
+ if (prior === undefined) {
1316
+ // The lock-nothing case: serialize with any concurrent creator of the
1317
+ // same chunk and re-read, so the usage delta below reflects the row it
1318
+ // committed (see `_lockSameKeyCreate`).
1319
+ await this._lockSameKeyCreate({
1320
+ client,
1321
+ spaceId,
1322
+ rowKey: `${collectionId}/${chunkLabel}`
1323
+ });
1324
+ const { rows: reread } = await client.query(`SELECT version, size FROM chunks
1325
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1326
+ AND chunk_index = $4
1327
+ FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
1328
+ prior = reread[0];
1329
+ }
1330
+ const exists = prior !== undefined;
1331
+ if (ifMatch !== undefined || ifNoneMatch) {
1332
+ assertWritePrecondition({
1333
+ resourceId: chunkLabel,
1334
+ exists,
1335
+ currentVersion: prior?.version ?? 0,
1336
+ ifMatch,
1337
+ ifNoneMatch
1338
+ });
1339
+ }
1340
+ const version = (prior?.version ?? 0) + 1;
1341
+ const priorSize = exists ? Number(prior?.size ?? 0) : 0;
1342
+ const delta = bytes.length - priorSize;
1343
+ if (delta !== 0) {
1344
+ await this._applyUsageDelta({ client, spaceId, delta });
1345
+ }
1346
+ const values = [
1347
+ spaceId,
1348
+ collectionId,
1349
+ resourceId,
1350
+ chunkIndex,
1351
+ input.contentType,
1352
+ bytes,
1353
+ bytes.length,
1354
+ version
1355
+ ];
1356
+ const insertSql = `
1357
+ INSERT INTO chunks (
1358
+ space_id, collection_id, resource_id, chunk_index,
1359
+ content_type, bytes, size, version
1360
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`;
1361
+ // Create-if-absent atomicity mirrors `writeResource`: concurrent
1362
+ // creators through this method are serialized by `_lockSameKeyCreate`
1363
+ // above, and against a writer that does not take that lock
1364
+ // (`importSpace`'s plain INSERTs) the plain INSERT keeps the primary key
1365
+ // as the arbiter -- a racing creator's unique violation maps to the 412
1366
+ // the precondition would have thrown.
1367
+ if (ifNoneMatch && prior === undefined) {
1368
+ try {
1369
+ await client.query(insertSql, values);
1370
+ }
1371
+ catch (err) {
1372
+ if (err.code === '23505') {
1373
+ throw new PreconditionFailedError({
1374
+ detail: `Chunk '${chunkLabel}' already exists (If-None-Match: *).`
1375
+ });
1376
+ }
1377
+ throw err;
1378
+ }
1379
+ return { version };
1380
+ }
1381
+ // The conflict update derives `version` from the row (`chunks.version +
1382
+ // 1`), not from the pre-read, so a concurrent creator that slipped in
1383
+ // after our lock-nothing SELECT still advances the counter monotonically.
1384
+ // RETURNING reports the version that actually landed.
1385
+ const { rows: written } = await client.query(`${insertSql}
1386
+ ON CONFLICT (space_id, collection_id, resource_id, chunk_index)
1387
+ DO UPDATE SET
1388
+ content_type = EXCLUDED.content_type,
1389
+ bytes = EXCLUDED.bytes,
1390
+ size = EXCLUDED.size,
1391
+ version = chunks.version + 1
1392
+ RETURNING version`, values);
1393
+ return { version: written[0].version };
1394
+ });
1395
+ }
1396
+ /**
1397
+ * Reads a chunk's bytes. Rejects with `ResourceNotFoundError` (404) when no
1398
+ * chunk is stored at that index.
1399
+ * @param options {object}
1400
+ * @param options.spaceId {string}
1401
+ * @param options.collectionId {string}
1402
+ * @param options.resourceId {string}
1403
+ * @param options.chunkIndex {number}
1404
+ * @returns {Promise<ResourceResult>}
1405
+ */
1406
+ async getChunk({ spaceId, collectionId, resourceId, chunkIndex }) {
1407
+ const { rows } = await this._pool.query(`SELECT content_type, bytes, version FROM chunks
1408
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1409
+ AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
1410
+ const row = rows[0];
1411
+ if (!row) {
1412
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' });
1413
+ }
1414
+ return {
1415
+ resourceStream: Readable.from(row.bytes),
1416
+ storedResourceType: row.content_type,
1417
+ version: row.version
1418
+ };
1419
+ }
1420
+ /**
1421
+ * Reads a chunk's stored content-type / size / version (the HEAD payload
1422
+ * headers). Resolves `undefined` when the chunk is absent.
1423
+ * @param options {object}
1424
+ * @param options.spaceId {string}
1425
+ * @param options.collectionId {string}
1426
+ * @param options.resourceId {string}
1427
+ * @param options.chunkIndex {number}
1428
+ * @returns {Promise<{ contentType: string, size: number, version?: number }
1429
+ * | undefined>}
1430
+ */
1431
+ async getChunkMetadata({ spaceId, collectionId, resourceId, chunkIndex }) {
1432
+ const { rows } = await this._pool.query(`SELECT content_type, size, version FROM chunks
1433
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1434
+ AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
1435
+ const row = rows[0];
1436
+ if (!row) {
1437
+ return undefined;
1438
+ }
1439
+ return {
1440
+ contentType: row.content_type,
1441
+ size: Number(row.size),
1442
+ version: row.version
1443
+ };
1444
+ }
1445
+ /**
1446
+ * Deletes one chunk as one transaction (the chunk row is locked, its
1447
+ * `ifMatch` precondition evaluated atomically, and its bytes returned to the
1448
+ * quota counter). Resolves `true` when a chunk was removed, `false` when none
1449
+ * was stored at that index (the handler 404s on `false` -- chunk deletes are
1450
+ * not silently idempotent, unlike `deleteResource`).
1451
+ * @param options {object}
1452
+ * @param options.spaceId {string}
1453
+ * @param options.collectionId {string}
1454
+ * @param options.resourceId {string}
1455
+ * @param options.chunkIndex {number}
1456
+ * @param [options.ifMatch] {string}
1457
+ * @returns {Promise<boolean>}
1458
+ */
1459
+ async deleteChunk({ spaceId, collectionId, resourceId, chunkIndex, ifMatch }) {
1460
+ return this._withTransaction(async (client) => {
1461
+ const { rows } = await client.query(`SELECT version, size FROM chunks
1462
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1463
+ AND chunk_index = $4
1464
+ FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
1465
+ const prior = rows[0];
1466
+ if (prior === undefined) {
1467
+ return false;
1468
+ }
1469
+ if (ifMatch !== undefined) {
1470
+ assertWritePrecondition({
1471
+ resourceId: `${resourceId}/chunks/${chunkIndex}`,
1472
+ exists: true,
1473
+ currentVersion: prior.version,
1474
+ ifMatch
1475
+ });
1476
+ }
1477
+ await client.query(`DELETE FROM chunks
1478
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1479
+ AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
1480
+ const freedBytes = Number(prior.size);
1481
+ if (freedBytes > 0) {
1482
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
1483
+ }
1484
+ return true;
1485
+ });
1486
+ }
1487
+ /**
1488
+ * Lists a Resource's stored chunks in ascending `chunk_index` order -- the
1489
+ * discovery/reassembly listing. The opaque `bytes` column is deliberately not
1490
+ * selected. An empty listing (Resource with no chunks, or an absent Resource)
1491
+ * resolves `{ count: 0, chunks: [] }`.
1492
+ * @param options {object}
1493
+ * @param options.spaceId {string}
1494
+ * @param options.collectionId {string}
1495
+ * @param options.resourceId {string}
1496
+ * @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
1497
+ * number, contentType: string, version?: number }> }>}
1498
+ */
1499
+ async listChunks({ spaceId, collectionId, resourceId }) {
1500
+ const { rows } = await this._pool.query(`SELECT chunk_index, size, content_type, version FROM chunks
1501
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1502
+ ORDER BY chunk_index`, [spaceId, collectionId, resourceId]);
1503
+ return {
1504
+ count: rows.length,
1505
+ chunks: rows.map(row => ({
1506
+ index: row.chunk_index,
1507
+ size: Number(row.size),
1508
+ contentType: row.content_type,
1509
+ version: row.version
1510
+ }))
1511
+ };
1512
+ }
1171
1513
  /**
1172
1514
  * Replication change feed (the `changes` query profile): one indexed keyset
1173
1515
  * query over `(updatedAt, resourceId)`, tombstones included, JSON documents
@@ -1678,7 +2020,7 @@ export class PostgresBackend {
1678
2020
  if (!spaceDescription) {
1679
2021
  throw new SpaceNotFoundError({ requestName: 'Export Space' });
1680
2022
  }
1681
- const [{ rows: policyRows }, { rows: collectionRows }, { rows: resourceRows }, { rows: revocationRows }] = await Promise.all([
2023
+ const [{ rows: policyRows }, { rows: collectionRows }, { rows: resourceRows }, { rows: revocationRows }, { rows: chunkRows }] = await Promise.all([
1682
2024
  this._pool.query(`SELECT collection_id, resource_id, policy FROM policies
1683
2025
  WHERE space_id = $1`, [spaceId]),
1684
2026
  this._pool.query(`SELECT collection_id, description, description_version FROM collections
@@ -1690,7 +2032,12 @@ export class PostgresBackend {
1690
2032
  created_at, updated_at, created_by
1691
2033
  FROM resources WHERE space_id = $1`, [spaceId]),
1692
2034
  this._pool.query(`SELECT delegator, capability_id, record FROM space_revocations
1693
- WHERE space_id = $1`, [spaceId])
2035
+ WHERE space_id = $1`, [spaceId]),
2036
+ // Chunk metadata only -- bytes are fetched one chunk at a time while
2037
+ // packing, so an export never holds a chunked Resource whole in memory.
2038
+ this._pool.query(`SELECT collection_id, resource_id, chunk_index, content_type, version
2039
+ FROM chunks WHERE space_id = $1
2040
+ ORDER BY collection_id, resource_id, chunk_index`, [spaceId])
1694
2041
  ]);
1695
2042
  // Assemble the per-entry file lists in the filesystem's shapes: files are
1696
2043
  // named by the shared codecs and sorted with localeCompare, matching the
@@ -1765,6 +2112,46 @@ export class PostgresBackend {
1765
2112
  });
1766
2113
  }
1767
2114
  }
2115
+ // Chunks (the `chunked-streams` feature): each chunked Resource contributes
2116
+ // one `.chunks.<encResourceId>/` subdirectory in its Collection dir, in the
2117
+ // exact filesystem-backend layout so an archive imports into either backend.
2118
+ // A chunk is stored there as a Resource keyed by its stringified index: an
2119
+ // `r.<index>.<encContentType>.<ext>` representation file (`fileNameFor`)
2120
+ // plus a `.meta.<index>.json` version sidecar. Files within a chunk dir are
2121
+ // sorted by name (the filesystem's readdir sort). Rows arrive ordered by
2122
+ // `(collection, resource, index)`.
2123
+ const chunkDirsByResource = new Map();
2124
+ for (const row of chunkRows) {
2125
+ const dirKey = `${row.collection_id}/${row.resource_id}`;
2126
+ let chunkFiles = chunkDirsByResource.get(dirKey);
2127
+ if (!chunkFiles) {
2128
+ chunkFiles = [];
2129
+ chunkDirsByResource.set(dirKey, chunkFiles);
2130
+ filesFor(row.collection_id).push({
2131
+ name: chunkDirName(row.resource_id),
2132
+ chunkDir: chunkFiles
2133
+ });
2134
+ }
2135
+ const chunkId = String(row.chunk_index);
2136
+ chunkFiles.push({
2137
+ name: fileNameFor({
2138
+ resourceId: chunkId,
2139
+ contentType: row.content_type
2140
+ }),
2141
+ chunk: {
2142
+ collectionId: row.collection_id,
2143
+ resourceId: row.resource_id,
2144
+ chunkIndex: row.chunk_index
2145
+ }
2146
+ });
2147
+ chunkFiles.push({
2148
+ name: `.meta.${chunkId}.json`,
2149
+ bytes: Buffer.from(JSON.stringify({ version: row.version }))
2150
+ });
2151
+ }
2152
+ for (const chunkFiles of chunkDirsByResource.values()) {
2153
+ chunkFiles.sort((left, right) => left.name.localeCompare(right.name));
2154
+ }
1768
2155
  // Top-level order: space-level files and collection dirs interleaved,
1769
2156
  // sorted by name -- the same order the filesystem's readdir+sort yields.
1770
2157
  const topLevel = [
@@ -1791,33 +2178,59 @@ export class PostgresBackend {
1791
2178
  const manifest = buildExportManifest({
1792
2179
  spaceId,
1793
2180
  entries: topLevel.map(entry => entry.kind === 'collection'
1794
- ? { name: entry.name, files: entry.files.map(file => file.name) }
2181
+ ? {
2182
+ name: entry.name,
2183
+ // A chunk directory expands to its `.chunks.<encId>/<file>`
2184
+ // relative paths (mirroring the pack order), so the manifest
2185
+ // matches the filesystem backend's for the same Space.
2186
+ files: entry.files.flatMap(file => 'chunkDir' in file
2187
+ ? file.chunkDir.map(child => `${file.name}/${child.name}`)
2188
+ : [file.name])
2189
+ }
1795
2190
  : { name: entry.name }),
1796
2191
  revocationFiles: revocationFiles.map(file => file.name)
1797
2192
  });
2193
+ // Fixed mtime on every entry so the archive is byte-reproducible (see
2194
+ // EXPORT_ENTRY_MTIME).
2195
+ const mtime = EXPORT_ENTRY_MTIME;
1798
2196
  const pack = tar.pack();
1799
- pack.entry({ name: 'manifest.yml' }, YAML.stringify(manifest));
1800
- pack.entry({ name: 'space/', type: 'directory' });
1801
- pack.entry({ name: `space/${spaceId}/`, type: 'directory' });
2197
+ pack.entry({ name: 'manifest.yml', mtime }, YAML.stringify(manifest));
2198
+ pack.entry({ name: 'space/', type: 'directory', mtime });
2199
+ pack.entry({ name: `space/${spaceId}/`, type: 'directory', mtime });
1802
2200
  for (const entry of topLevel) {
1803
2201
  const entryTarget = `space/${spaceId}/${entry.name}`;
1804
2202
  if (entry.kind === 'collection') {
1805
- pack.entry({ name: `${entryTarget}/`, type: 'directory' });
2203
+ pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime });
1806
2204
  for (const file of entry.files) {
2205
+ if ('chunkDir' in file) {
2206
+ // A chunked Resource's `.chunks.<encId>/` subdirectory: emit the
2207
+ // directory entry, then each file -- an inline `.meta.<index>.json`
2208
+ // sidecar, or a chunk representation whose bytes are fetched one at
2209
+ // a time.
2210
+ const dirTarget = `${entryTarget}/${file.name}`;
2211
+ pack.entry({ name: `${dirTarget}/`, type: 'directory', mtime });
2212
+ for (const chunkFile of file.chunkDir) {
2213
+ const bytes = 'bytes' in chunkFile
2214
+ ? chunkFile.bytes
2215
+ : await this._chunkContent({ spaceId, ...chunkFile.chunk });
2216
+ pack.entry({ name: `${dirTarget}/${chunkFile.name}`, mtime }, bytes);
2217
+ }
2218
+ continue;
2219
+ }
1807
2220
  const bytes = 'bytes' in file
1808
2221
  ? file.bytes
1809
2222
  : await this._resourceContent({ spaceId, ...file.resource });
1810
- pack.entry({ name: `${entryTarget}/${file.name}` }, bytes);
2223
+ pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes);
1811
2224
  }
1812
2225
  }
1813
2226
  else {
1814
- pack.entry({ name: entryTarget }, entry.bytes);
2227
+ pack.entry({ name: entryTarget, mtime }, entry.bytes);
1815
2228
  }
1816
2229
  }
1817
2230
  if (revocationFiles.length > 0) {
1818
- pack.entry({ name: 'revocations/', type: 'directory' });
2231
+ pack.entry({ name: 'revocations/', type: 'directory', mtime });
1819
2232
  for (const file of revocationFiles) {
1820
- pack.entry({ name: `revocations/${file.name}` }, file.bytes);
2233
+ pack.entry({ name: `revocations/${file.name}`, mtime }, file.bytes);
1821
2234
  }
1822
2235
  }
1823
2236
  pack.finalize();
@@ -1839,6 +2252,24 @@ export class PostgresBackend {
1839
2252
  WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1840
2253
  return rows[0]?.content ?? Buffer.alloc(0);
1841
2254
  }
2255
+ /**
2256
+ * Fetches one chunk's bytes for the export pack loop (the `chunked-streams`
2257
+ * feature). A chunk removed between the metadata pass and this read yields an
2258
+ * empty body rather than failing the whole archive, matching
2259
+ * `_resourceContent`.
2260
+ * @param options {object}
2261
+ * @param options.spaceId {string}
2262
+ * @param options.collectionId {string}
2263
+ * @param options.resourceId {string}
2264
+ * @param options.chunkIndex {number}
2265
+ * @returns {Promise<Buffer>}
2266
+ */
2267
+ async _chunkContent({ spaceId, collectionId, resourceId, chunkIndex }) {
2268
+ const { rows } = await this._pool.query(`SELECT bytes FROM chunks
2269
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2270
+ AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
2271
+ return rows[0]?.bytes ?? Buffer.alloc(0);
2272
+ }
1842
2273
  /**
1843
2274
  * Merges a WAS space-export tarball into an existing Space with the same
1844
2275
  * three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
@@ -1855,6 +2286,10 @@ export class PostgresBackend {
1855
2286
  async importSpace({ spaceId, tarStream }) {
1856
2287
  const entries = await extractTarEntries(tarStream);
1857
2288
  const { spacePolicy, collections, revocations } = buildImportPlan(entries);
2289
+ // Chunk entries (the `chunked-streams` feature) live in per-Resource
2290
+ // `.chunks.<encId>/` subdirectories, which `buildImportPlan` skips (it
2291
+ // ignores nested files); parse them straight off the raw tar entries.
2292
+ const chunkEntries = this._chunkEntriesFromArchive(entries);
1858
2293
  const { capacityBytes, maxUploadBytes, maxCollectionsPerSpace, maxResourcesPerSpace } = this;
1859
2294
  return this._withTransaction(async (client) => {
1860
2295
  await this._ensureSpaceRow({ client, spaceId });
@@ -1918,6 +2353,19 @@ export class PostgresBackend {
1918
2353
  incomingBytes += body.length;
1919
2354
  }
1920
2355
  }
2356
+ // Chunks (the `chunked-streams` feature) are opaque bytes -- no
2357
+ // encryption-conformance check applies -- but they count the same per-body
2358
+ // 413 and the (conservative) capacity pre-flight as Resource bodies.
2359
+ for (const chunk of chunkEntries) {
2360
+ if (chunk.body.length > maxUploadBytes) {
2361
+ throw new PayloadTooLargeError({
2362
+ maxUploadBytes,
2363
+ backendId: this.describe().id,
2364
+ uploadBytes: chunk.body.length
2365
+ });
2366
+ }
2367
+ incomingBytes += chunk.body.length;
2368
+ }
1921
2369
  if (capacityBytes !== undefined &&
1922
2370
  currentUsage + incomingBytes > capacityBytes) {
1923
2371
  throw new QuotaExceededError({ spaceId, capacityBytes });
@@ -2081,13 +2529,53 @@ export class PostgresBackend {
2081
2529
  stats.resourcesCreated++;
2082
2530
  }
2083
2531
  }
2084
- if (createdBytes > 0) {
2532
+ // Chunks (the `chunked-streams` feature): restore each archived chunk
2533
+ // skip-not-overwrite, after the Resource apply loop so a chunk's parent
2534
+ // Resource row already exists in this transaction (the foreign key
2535
+ // requires it). An orphan chunk -- one whose parent is absent or a
2536
+ // tombstone -- is skipped rather than resurrected. Existing chunk rows are
2537
+ // left untouched.
2538
+ let createdChunkBytes = 0;
2539
+ for (const chunk of chunkEntries) {
2540
+ const { rows: parentRows } = await client.query(`SELECT deleted FROM resources
2541
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, chunk.collectionId, chunk.resourceId]);
2542
+ const parent = parentRows[0];
2543
+ if (!parent || parent.deleted) {
2544
+ continue;
2545
+ }
2546
+ const { rows: existingChunkRows } = await client.query(`SELECT 1 FROM chunks
2547
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
2548
+ AND chunk_index = $4`, [spaceId, chunk.collectionId, chunk.resourceId, chunk.chunkIndex]);
2549
+ if (existingChunkRows.length > 0) {
2550
+ continue;
2551
+ }
2552
+ // The chunk's `version` comes from its archived `.meta.<index>.json`
2553
+ // sidecar; an archive without one (or a chunk written before sidecars)
2554
+ // starts at version 1, the same fresh-write default as a Resource
2555
+ // restored without a sidecar.
2556
+ await client.query(`INSERT INTO chunks (
2557
+ space_id, collection_id, resource_id, chunk_index,
2558
+ content_type, bytes, size, version
2559
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
2560
+ spaceId,
2561
+ chunk.collectionId,
2562
+ chunk.resourceId,
2563
+ chunk.chunkIndex,
2564
+ chunk.contentType,
2565
+ chunk.body,
2566
+ chunk.body.length,
2567
+ chunk.version ?? 1
2568
+ ]);
2569
+ createdChunkBytes += chunk.body.length;
2570
+ }
2571
+ const createdTotalBytes = createdBytes + createdChunkBytes;
2572
+ if (createdTotalBytes > 0) {
2085
2573
  // The pre-flight was conservative (it counted skips too), so the
2086
2574
  // actual created total always fits; apply it unguarded.
2087
2575
  await this._applyUsageDelta({
2088
2576
  client,
2089
2577
  spaceId,
2090
- delta: createdBytes
2578
+ delta: createdTotalBytes
2091
2579
  });
2092
2580
  }
2093
2581
  // Restore the archive's Space-scoped zcap revocations under this
@@ -2160,5 +2648,113 @@ export class PostgresBackend {
2160
2648
  sidecar?.epoch ?? null
2161
2649
  ]);
2162
2650
  }
2651
+ /**
2652
+ * Parses a Space-export archive's chunk entries (the `chunked-streams`
2653
+ * feature) out of the raw tar entry map, in the filesystem backend's on-disk
2654
+ * layout so archives round-trip between the two backends. Chunk files live at
2655
+ * `space/<sourceSpaceId>/<collectionId>/.chunks.<encResourceId>/<file>` --
2656
+ * where a chunk is a Resource keyed by its stringified index: an
2657
+ * `r.<index>.<encContentType>.<ext>` representation paired with an optional
2658
+ * `.meta.<index>.json` version sidecar. `buildImportPlan` skips these (it
2659
+ * ignores nested Collection-dir files), so they are parsed here. Ids parsed
2660
+ * from the archive are validated (the path-traversal guard); a file whose
2661
+ * raw index segment is not canonical (`parseChunkIndexSegment`) is ignored,
2662
+ * and a sidecar with no paired representation is dropped (a chunk keeps no
2663
+ * tombstone).
2664
+ * @param entries {Map<string, TarEntry>}
2665
+ * @returns {Array<{ collectionId: string, resourceId: string, chunkIndex:
2666
+ * number, contentType: string, body: Buffer, version?: number }>}
2667
+ */
2668
+ _chunkEntriesFromArchive(entries) {
2669
+ // Accumulate the representation and the sidecar of each chunk under one
2670
+ // key, then emit only the chunks that carry a representation.
2671
+ const staged = new Map();
2672
+ const pattern = /^space\/[^/]+\/([^/]+)\/(\.chunks\.[^/]+)\/([^/]+)$/;
2673
+ for (const [entryName, entry] of entries) {
2674
+ if (entry.type !== 'file' || !entry.body) {
2675
+ continue;
2676
+ }
2677
+ const match = entryName.match(pattern);
2678
+ if (!match) {
2679
+ continue;
2680
+ }
2681
+ const collectionId = match[1];
2682
+ const resourceId = parseChunkDirName(match[2]);
2683
+ const fileName = match[3];
2684
+ if (resourceId === undefined) {
2685
+ continue;
2686
+ }
2687
+ // Reject a path-traversal / non-URL-safe id parsed from the archive
2688
+ // before it keys a destination row.
2689
+ assertValidId(collectionId, {
2690
+ kind: 'collection',
2691
+ requestName: 'Import Space'
2692
+ });
2693
+ assertValidId(resourceId, {
2694
+ kind: 'resource',
2695
+ requestName: 'Import Space'
2696
+ });
2697
+ // A chunk's index is the "resource id" of its file within the chunk dir:
2698
+ // `r.<index>.<encType>.<ext>` for the representation, `.meta.<index>.json`
2699
+ // for the version sidecar. The RAW index segment must pass the same
2700
+ // canonical predicate as the live route (`parseChunkIndexSegment`), so a
2701
+ // non-canonical spelling (`r.01.*`, `r.%31.*`) is dropped rather than
2702
+ // coerced onto -- and colliding with -- the canonical chunk's row.
2703
+ let chunkIdSegment;
2704
+ let representation;
2705
+ let sidecarVersion;
2706
+ const metaId = metaSidecarFileId(fileName);
2707
+ if (metaId !== undefined) {
2708
+ chunkIdSegment = metaId;
2709
+ try {
2710
+ const sidecar = JSON.parse(entry.body.toString('utf8'));
2711
+ sidecarVersion = sidecar.version;
2712
+ }
2713
+ catch {
2714
+ sidecarVersion = undefined;
2715
+ }
2716
+ }
2717
+ else if (fileName.startsWith('r.')) {
2718
+ const { contentType } = parseResourceFileName(fileName);
2719
+ chunkIdSegment = fileName.split('.')[1];
2720
+ representation = { contentType, body: entry.body };
2721
+ }
2722
+ else {
2723
+ continue;
2724
+ }
2725
+ const chunkIndex = chunkIdSegment === undefined
2726
+ ? undefined
2727
+ : parseChunkIndexSegment(chunkIdSegment);
2728
+ if (chunkIndex === undefined) {
2729
+ continue;
2730
+ }
2731
+ const key = `${collectionId}/${resourceId}/${chunkIndex}`;
2732
+ const slot = staged.get(key) ?? { collectionId, resourceId, chunkIndex };
2733
+ if (representation !== undefined) {
2734
+ slot.contentType = representation.contentType;
2735
+ slot.body = representation.body;
2736
+ }
2737
+ if (sidecarVersion !== undefined) {
2738
+ slot.version = sidecarVersion;
2739
+ }
2740
+ staged.set(key, slot);
2741
+ }
2742
+ const parsed = [];
2743
+ for (const slot of staged.values()) {
2744
+ if (slot.body === undefined) {
2745
+ // A sidecar with no paired representation is not a valid chunk.
2746
+ continue;
2747
+ }
2748
+ parsed.push({
2749
+ collectionId: slot.collectionId,
2750
+ resourceId: slot.resourceId,
2751
+ chunkIndex: slot.chunkIndex,
2752
+ contentType: slot.contentType ?? 'application/octet-stream',
2753
+ body: slot.body,
2754
+ version: slot.version
2755
+ });
2756
+ }
2757
+ return parsed;
2758
+ }
2163
2759
  }
2164
2760
  //# sourceMappingURL=postgres.js.map