was-teaching-server 0.9.1 → 0.11.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 (76) hide show
  1. package/CHANGELOG.md +118 -1
  2. package/README.md +23 -11
  3. package/dist/backends/filesystem.d.ts +247 -4
  4. package/dist/backends/filesystem.d.ts.map +1 -1
  5. package/dist/backends/filesystem.js +711 -81
  6. package/dist/backends/filesystem.js.map +1 -1
  7. package/dist/backends/postgres.d.ts +240 -2
  8. package/dist/backends/postgres.d.ts.map +1 -1
  9. package/dist/backends/postgres.js +800 -42
  10. package/dist/backends/postgres.js.map +1 -1
  11. package/dist/backends/postgresSchema.d.ts.map +1 -1
  12. package/dist/backends/postgresSchema.js +69 -57
  13. package/dist/backends/postgresSchema.js.map +1 -1
  14. package/dist/errors.d.ts +26 -9
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +40 -12
  17. package/dist/errors.js.map +1 -1
  18. package/dist/lib/equalityIndex.d.ts +334 -0
  19. package/dist/lib/equalityIndex.d.ts.map +1 -0
  20. package/dist/lib/equalityIndex.js +552 -0
  21. package/dist/lib/equalityIndex.js.map +1 -0
  22. package/dist/lib/exportManifest.d.ts +9 -0
  23. package/dist/lib/exportManifest.d.ts.map +1 -1
  24. package/dist/lib/exportManifest.js +9 -0
  25. package/dist/lib/exportManifest.js.map +1 -1
  26. package/dist/lib/importTar.d.ts +18 -2
  27. package/dist/lib/importTar.d.ts.map +1 -1
  28. package/dist/lib/importTar.js +77 -4
  29. package/dist/lib/importTar.js.map +1 -1
  30. package/dist/lib/paths.d.ts +33 -0
  31. package/dist/lib/paths.d.ts.map +1 -1
  32. package/dist/lib/paths.js +28 -0
  33. package/dist/lib/paths.js.map +1 -1
  34. package/dist/lib/resourceFileName.d.ts +43 -0
  35. package/dist/lib/resourceFileName.d.ts.map +1 -1
  36. package/dist/lib/resourceFileName.js +63 -0
  37. package/dist/lib/resourceFileName.js.map +1 -1
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +5 -2
  40. package/dist/plugin.js.map +1 -1
  41. package/dist/requests/ChunkRequest.d.ts +104 -0
  42. package/dist/requests/ChunkRequest.d.ts.map +1 -0
  43. package/dist/requests/ChunkRequest.js +396 -0
  44. package/dist/requests/ChunkRequest.js.map +1 -0
  45. package/dist/requests/CollectionRequest.d.ts +22 -5
  46. package/dist/requests/CollectionRequest.d.ts.map +1 -1
  47. package/dist/requests/CollectionRequest.js +192 -11
  48. package/dist/requests/CollectionRequest.js.map +1 -1
  49. package/dist/requests/ResourceRequest.d.ts.map +1 -1
  50. package/dist/requests/ResourceRequest.js +20 -0
  51. package/dist/requests/ResourceRequest.js.map +1 -1
  52. package/dist/requests/SpaceRequest.d.ts +1 -0
  53. package/dist/requests/SpaceRequest.d.ts.map +1 -1
  54. package/dist/requests/SpaceRequest.js +21 -1
  55. package/dist/requests/SpaceRequest.js.map +1 -1
  56. package/dist/routes.d.ts.map +1 -1
  57. package/dist/routes.js +21 -0
  58. package/dist/routes.js.map +1 -1
  59. package/dist/types.d.ts +165 -2
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +19 -18
  62. package/src/backends/filesystem.ts +924 -75
  63. package/src/backends/postgres.ts +1114 -32
  64. package/src/backends/postgresSchema.ts +69 -57
  65. package/src/errors.ts +44 -12
  66. package/src/lib/equalityIndex.ts +769 -0
  67. package/src/lib/importTar.ts +107 -4
  68. package/src/lib/paths.ts +48 -0
  69. package/src/lib/resourceFileName.ts +69 -0
  70. package/src/plugin.ts +5 -2
  71. package/src/requests/ChunkRequest.ts +478 -0
  72. package/src/requests/CollectionRequest.ts +221 -12
  73. package/src/requests/ResourceRequest.ts +20 -0
  74. package/src/requests/SpaceRequest.ts +25 -1
  75. package/src/routes.ts +46 -0
  76. package/src/types.ts +175 -1
@@ -19,12 +19,12 @@ import YAML from 'yaml';
19
19
  import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_MAX_SPACES_PER_CONTROLLER, DEFAULT_MAX_COLLECTIONS_PER_SPACE, DEFAULT_MAX_RESOURCES_PER_SPACE, QUOTA_USAGE_CACHE_TTL, normalizeCountLimit } from '../config.default.js';
20
20
  import { extractTarEntries, buildImportPlan, metaSidecarFileId } from '../lib/importTar.js';
21
21
  import { collectionPath, resourcePath } from '../lib/paths.js';
22
- import { encodeFilenameSegment, fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js';
22
+ import { encodeFilenameSegment, fileNameFor, parseResourceFileName, chunkDirName, CHUNK_DIR_PREFIX } from '../lib/resourceFileName.js';
23
23
  import { sanitizeBackendRecord } from '../lib/backends.js';
24
24
  import { backendUsageFields } from '../lib/backendUsage.js';
25
25
  import { assertEncryptedWriteConforms } from '../lib/encryption.js';
26
26
  import { encodeCursor, decodeCursor } from '../lib/cursor.js';
27
- import { buildExportManifest } from '../lib/exportManifest.js';
27
+ import { buildExportManifest, EXPORT_ENTRY_MTIME } from '../lib/exportManifest.js';
28
28
  import { revocationFileName } from '../lib/revocations.js';
29
29
  import { KeyedMutex } from '../lib/keyedMutex.js';
30
30
  import { isJson } from '../lib/isJson.js';
@@ -32,6 +32,7 @@ import { normalizeDescriptionWrite } from '../lib/collectionDescription.js';
32
32
  import { atomicWriteFile, atomicCreateFile, tempPathFor, commitTempFile } from '../lib/atomicFile.js';
33
33
  import { DEFAULT_PAGE_SIZE, clampPageSize } from '../lib/pagination.js';
34
34
  import { runBlindedIndexQuery, collectUniqueBlindedTerms, assertNoUniqueBlindedConflict } from '../lib/blindedIndex.js';
35
+ import { runEqualityQuery, assertNoUniqueEqualityConflict, findEqualityUniqueViolation } from '../lib/equalityIndex.js';
35
36
  import { assertWritePrecondition, assertMetaWritePrecondition, assertCollectionWritePrecondition } from '../lib/preconditions.js';
36
37
  const { Store: MetadataJsonStore } = jsonfs;
37
38
  const execFileAsync = promisify(execFile);
@@ -203,8 +204,9 @@ export class FileSystemBackend {
203
204
  * It advertises the `conditional-writes` affordance: it exposes a per-Resource
204
205
  * `version` as an HTTP `ETag` validator and honors `If-Match` / `If-None-Match`
205
206
  * write preconditions atomically (returning `412 precondition-failed` on a
206
- * mismatch). The remaining `features` vocabulary token (`chunked-streams`)
207
- * is not implemented yet and is added when it lands.
207
+ * mismatch). It also advertises `chunked-streams`: chunk addressing for large
208
+ * Resources (`/{resourceId}/chunks/{n}`), each chunk stored opaquely like a
209
+ * binary Resource representation.
208
210
  * (Client-side encryption is deliberately not a backend feature: encrypted
209
211
  * documents are opaque client-encrypted JSON this backend already stores
210
212
  * faithfully, with no server cooperation.)
@@ -229,11 +231,18 @@ export class FileSystemBackend {
229
231
  // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
230
232
  // keys on the `encryption` marker, a client-declared `epoch` stamp on
231
233
  // Resources, and conditional (`If-Match`) Collection Description writes.
234
+ // `chunked-streams`: chunk addressing (`/{resourceId}/chunks/{n}`) for a
235
+ // large Resource, each chunk stored opaquely (bytes + content-type).
236
+ // `equality-query`: serves the `equality` profile -- server-extracted
237
+ // plaintext attribute equality over a Collection's declared `indexes`
238
+ // (`queryByEquality`), plus the GET `filter[attr]=value` equality filter.
232
239
  features: [
233
240
  'conditional-writes',
234
241
  'changes-query',
235
242
  'blinded-index-query',
236
- 'key-epochs'
243
+ 'equality-query',
244
+ 'key-epochs',
245
+ 'chunked-streams'
237
246
  ]
238
247
  };
239
248
  }
@@ -812,6 +821,11 @@ export class FileSystemBackend {
812
821
  entry => !(entry.isFile() && entry.name.startsWith('.backend.')));
813
822
  spaceEntries.sort((a, b) => a.name.localeCompare(b.name));
814
823
  const collectionEntriesByDir = {};
824
+ // Per-Resource chunk directories (`.chunks.<encId>/`; the `chunked-streams`
825
+ // feature) are subdirectories of a Collection dir, so the file filter below
826
+ // skips them. Gather each one's files here so a chunked Resource's chunks
827
+ // travel in the export, keyed by Collection then by chunk-directory name.
828
+ const chunkDirsByCollection = {};
815
829
  for (const entry of spaceEntries) {
816
830
  if (!entry.isDirectory()) {
817
831
  continue;
@@ -820,6 +834,17 @@ export class FileSystemBackend {
820
834
  collectionEntriesByDir[entry.name] = entries
821
835
  .filter(e => e.isFile())
822
836
  .sort((a, b) => a.name.localeCompare(b.name));
837
+ const chunkDirs = [];
838
+ for (const sub of entries
839
+ .filter(e => e.isDirectory() && e.name.startsWith(CHUNK_DIR_PREFIX))
840
+ .sort((a, b) => a.name.localeCompare(b.name))) {
841
+ const files = (await fs.promises.readdir(path.join(sourceSpaceDir, entry.name, sub.name), { withFileTypes: true }))
842
+ .filter(e => e.isFile())
843
+ .map(e => e.name)
844
+ .sort((a, b) => a.localeCompare(b));
845
+ chunkDirs.push({ dirName: sub.name, files });
846
+ }
847
+ chunkDirsByCollection[entry.name] = chunkDirs;
823
848
  }
824
849
  // Space-scoped zcap revocations travel with the export. They live in the
825
850
  // sibling `spaceRevocationsDir` root (see that property's doc), so the
@@ -844,35 +869,58 @@ export class FileSystemBackend {
844
869
  entries: spaceEntries.map(entry => entry.isDirectory()
845
870
  ? {
846
871
  name: entry.name,
847
- files: (collectionEntriesByDir[entry.name] ?? []).map(file => file.name)
872
+ // The Collection's own files, then each chunked Resource's chunk
873
+ // files listed by their `.chunks.<encId>/<file>` relative path
874
+ // (the manifest mirrors the pack order below).
875
+ files: [
876
+ ...(collectionEntriesByDir[entry.name] ?? []).map(file => file.name),
877
+ ...(chunkDirsByCollection[entry.name] ?? []).flatMap(({ dirName, files }) => files.map(file => `${dirName}/${file}`))
878
+ ]
848
879
  }
849
880
  : // top-level files in space (e.g. .space.<spaceId>.json)
850
881
  { name: entry.name }),
851
882
  revocationFiles
852
883
  });
884
+ // Fixed mtime on every entry so the archive is byte-reproducible (see
885
+ // EXPORT_ENTRY_MTIME).
886
+ const mtime = EXPORT_ENTRY_MTIME;
853
887
  const pack = tar.pack();
854
- pack.entry({ name: 'manifest.yml' }, YAML.stringify(manifest));
855
- pack.entry({ name: 'space/', type: 'directory' });
856
- pack.entry({ name: `space/${spaceId}/`, type: 'directory' });
888
+ pack.entry({ name: 'manifest.yml', mtime }, YAML.stringify(manifest));
889
+ pack.entry({ name: 'space/', type: 'directory', mtime });
890
+ pack.entry({ name: `space/${spaceId}/`, type: 'directory', mtime });
857
891
  for (const entry of spaceEntries) {
858
892
  const entryTarget = `space/${spaceId}/${entry.name}`;
859
893
  if (entry.isDirectory()) {
860
- pack.entry({ name: `${entryTarget}/`, type: 'directory' });
894
+ pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime });
861
895
  for (const file of collectionEntriesByDir[entry.name] ?? []) {
862
896
  const bytes = await fs.promises.readFile(path.join(sourceSpaceDir, entry.name, file.name));
863
- pack.entry({ name: `${entryTarget}/${file.name}` }, bytes);
897
+ pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes);
898
+ }
899
+ // A chunked Resource's chunk directory and its files, packed under the
900
+ // Collection so `.chunks.<encId>/<file>` round-trips on import.
901
+ for (const { dirName, files } of chunkDirsByCollection[entry.name] ??
902
+ []) {
903
+ pack.entry({
904
+ name: `${entryTarget}/${dirName}/`,
905
+ type: 'directory',
906
+ mtime
907
+ });
908
+ for (const file of files) {
909
+ const bytes = await fs.promises.readFile(path.join(sourceSpaceDir, entry.name, dirName, file));
910
+ pack.entry({ name: `${entryTarget}/${dirName}/${file}`, mtime }, bytes);
911
+ }
864
912
  }
865
913
  }
866
914
  else if (entry.isFile()) {
867
915
  const bytes = await fs.promises.readFile(path.join(sourceSpaceDir, entry.name));
868
- pack.entry({ name: entryTarget }, bytes);
916
+ pack.entry({ name: entryTarget, mtime }, bytes);
869
917
  }
870
918
  }
871
919
  if (revocationFiles.length > 0) {
872
- pack.entry({ name: 'revocations/', type: 'directory' });
920
+ pack.entry({ name: 'revocations/', type: 'directory', mtime });
873
921
  for (const name of revocationFiles) {
874
922
  const bytes = await fs.promises.readFile(path.join(revocationsDir, name));
875
- pack.entry({ name: `revocations/${name}` }, bytes);
923
+ pack.entry({ name: `revocations/${name}`, mtime }, bytes);
876
924
  }
877
925
  }
878
926
  pack.finalize();
@@ -903,7 +951,7 @@ export class FileSystemBackend {
903
951
  // (existing ids) are counted conservatively, as for the quota estimate.
904
952
  const { capacityBytes, maxUploadBytes, maxCollectionsPerSpace, maxResourcesPerSpace } = this;
905
953
  let incomingBytes = 0;
906
- for (const { collectionId, collectionDescription, resources } of collections) {
954
+ for (const { collectionId, collectionDescription, resources, chunkFiles } of collections) {
907
955
  const existing = await this.getCollectionDescription({
908
956
  spaceId,
909
957
  collectionId
@@ -936,6 +984,19 @@ export class FileSystemBackend {
936
984
  }
937
985
  incomingBytes += body.length;
938
986
  }
987
+ // Chunk files (the `chunked-streams` feature) inherit the per-upload cap
988
+ // and quota estimate, but NOT the encryption-conformance check: a chunk is
989
+ // opaque bytes, not a JSON envelope of the Collection's scheme.
990
+ for (const { body } of chunkFiles) {
991
+ if (maxUploadBytes !== undefined && body.length > maxUploadBytes) {
992
+ throw new PayloadTooLargeError({
993
+ maxUploadBytes,
994
+ backendId: this.describe().id,
995
+ uploadBytes: body.length
996
+ });
997
+ }
998
+ incomingBytes += body.length;
999
+ }
939
1000
  }
940
1001
  if (capacityBytes !== undefined) {
941
1002
  await this._assertSpaceHeadroom({
@@ -974,7 +1035,7 @@ export class FileSystemBackend {
974
1035
  let liveResourceCount = maxResourcesPerSpace !== undefined
975
1036
  ? await this._countLiveResources({ spaceId })
976
1037
  : 0;
977
- for (const { collectionId, collectionDescription, collectionPolicy, resources, resourcePolicies, resourceMetadata } of collections) {
1038
+ for (const { collectionId, collectionDescription, collectionPolicy, resources, resourcePolicies, resourceMetadata, chunkFiles } of collections) {
978
1039
  // check if collection already exists
979
1040
  const collectionExisted = Boolean(await this.getCollectionDescription({ spaceId, collectionId }));
980
1041
  if (collectionExisted) {
@@ -1104,6 +1165,43 @@ export class FileSystemBackend {
1104
1165
  });
1105
1166
  stats.resourcesCreated++;
1106
1167
  }
1168
+ // Restore chunk files of chunked Resources (the `chunked-streams` feature)
1169
+ // into their per-Resource chunk directories. Skip-not-overwrite, per chunk
1170
+ // file: an existing chunk file is left untouched, so a re-import never
1171
+ // clobbers stored chunk bytes (or their version sidecar). An ORPHAN chunk
1172
+ // file -- one whose parent Resource is absent or a tombstone (no live
1173
+ // representation on the destination after the Resource apply loop above)
1174
+ // -- is skipped rather than resurrected, matching the live write path's
1175
+ // parent-exists rule (and the Postgres import).
1176
+ const parentIsLive = new Map();
1177
+ for (const { resourceId, fileName, body } of chunkFiles) {
1178
+ let live = parentIsLive.get(resourceId);
1179
+ if (live === undefined) {
1180
+ live = Boolean(await this._findFile({ collectionDir, resourceId }));
1181
+ parentIsLive.set(resourceId, live);
1182
+ }
1183
+ if (!live) {
1184
+ continue;
1185
+ }
1186
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
1187
+ const target = path.join(chunkDir, fileName);
1188
+ this._assertContained(target);
1189
+ let present = false;
1190
+ try {
1191
+ await fsStat(target);
1192
+ present = true;
1193
+ }
1194
+ catch (err) {
1195
+ if (err.code !== 'ENOENT') {
1196
+ throw err;
1197
+ }
1198
+ }
1199
+ if (present) {
1200
+ continue;
1201
+ }
1202
+ await mkdir(chunkDir, { recursive: true });
1203
+ await atomicWriteFile({ filePath: target, data: body });
1204
+ }
1107
1205
  }
1108
1206
  // Restore the archive's Space-scoped zcap revocations under this Space's
1109
1207
  // scope: a capability revoked before the export must stay revoked after
@@ -1436,7 +1534,7 @@ export class FileSystemBackend {
1436
1534
  * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
1437
1535
  * @returns {Promise<{ version: number }>} the Resource's new version
1438
1536
  */
1439
- async writeResource({ spaceId, collectionId, resourceId, input, createdBy, epoch, ifMatch, ifNoneMatch }) {
1537
+ async writeResource({ spaceId, collectionId, resourceId, input, createdBy, epoch, uniqueIndexes, ifMatch, ifNoneMatch }) {
1440
1538
  const collectionDir = this._collectionDir({ spaceId, collectionId });
1441
1539
  const lockKey = this._resourceLockKey({
1442
1540
  spaceId,
@@ -1453,26 +1551,53 @@ export class FileSystemBackend {
1453
1551
  ifMatch,
1454
1552
  ifNoneMatch
1455
1553
  }));
1456
- // The EDV unique-attribute invariant (`unique: true` blinded attributes;
1457
- // the `blinded-index-query` feature): a write claiming a unique blinded
1458
- // triple must not collide with another live document's claim. Only a
1459
- // unique-carrying JSON write can create a new claim, so only such writes
1460
- // pay for it: they serialize per Collection (the outer lock, so two racing
1461
- // claimants cannot both pass the scan), evaluate the conflict against the
1462
- // Collection's other live JSON documents, then take the ordinary
1463
- // per-Resource lock nested inside. Distinct-key nesting cannot deadlock:
1464
- // plain writes never hold a Resource key while waiting on a Collection key.
1465
- if (input.kind === 'json' &&
1466
- collectUniqueBlindedTerms({ document: input.data }).length > 0) {
1554
+ // Two unique-attribute invariants can force a write to serialize on the
1555
+ // Collection lock before it takes its per-Resource lock: the EDV blinded
1556
+ // one (`unique: true` blinded attributes; the `blinded-index-query`
1557
+ // feature) and the plaintext equality one (a Collection's `unique`-declared
1558
+ // `indexes`; the `equality-query` feature). Only a JSON content write can
1559
+ // create either claim, so only such writes pay for it: they serialize per
1560
+ // Collection (the outer lock, so two racing claimants cannot both pass the
1561
+ // scan), evaluate the conflict against the Collection's other live
1562
+ // documents, then take the ordinary per-Resource lock nested inside.
1563
+ // Distinct-key nesting cannot deadlock: plain writes never hold a Resource
1564
+ // key while waiting on a Collection key. The two conditions are unified so a
1565
+ // write carrying both claims acquires the Collection lock exactly once.
1566
+ const blindedUnique = input.kind === 'json' &&
1567
+ collectUniqueBlindedTerms({ document: input.data }).length > 0;
1568
+ const equalityUnique = input.kind === 'json' &&
1569
+ uniqueIndexes !== undefined &&
1570
+ uniqueIndexes.length > 0;
1571
+ if (blindedUnique || equalityUnique) {
1467
1572
  return this._writeMutex.run(this._collectionLockKey({ spaceId, collectionId }), async () => {
1468
- assertNoUniqueBlindedConflict({
1469
- document: input.data,
1470
- candidates: await this._readJsonCandidates({
1471
- spaceId,
1472
- collectionId,
1473
- excludeResourceId: resourceId
1474
- })
1475
- });
1573
+ if (blindedUnique) {
1574
+ assertNoUniqueBlindedConflict({
1575
+ document: input.kind === 'json' ? input.data : undefined,
1576
+ candidates: await this._readJsonCandidates({
1577
+ spaceId,
1578
+ collectionId,
1579
+ excludeResourceId: resourceId
1580
+ })
1581
+ });
1582
+ }
1583
+ if (equalityUnique) {
1584
+ // A content write does not change the Resource's `custom`, so the
1585
+ // custom side of the claim comes from the CURRENT stored sidecar.
1586
+ const priorSidecar = await this._readMetaSidecar({
1587
+ collectionDir,
1588
+ resourceId
1589
+ });
1590
+ assertNoUniqueEqualityConflict({
1591
+ indexes: uniqueIndexes,
1592
+ content: input.kind === 'json' ? input.data : undefined,
1593
+ custom: priorSidecar?.custom,
1594
+ candidates: await this._readEqualityCandidates({
1595
+ spaceId,
1596
+ collectionId,
1597
+ excludeResourceId: resourceId
1598
+ })
1599
+ });
1600
+ }
1476
1601
  return write();
1477
1602
  });
1478
1603
  }
@@ -1515,6 +1640,70 @@ export class FileSystemBackend {
1515
1640
  }
1516
1641
  }
1517
1642
  }
1643
+ await this._writeRepresentationBytes({ spaceId, filePath, input });
1644
+ // A Resource has a single current representation: remove any prior
1645
+ // representation stored under a different content-type (its filename
1646
+ // differs). Write-new-then-prune (not delete-then-write) so the resource is
1647
+ // never momentarily absent.
1648
+ const existing = await this._resourceFilesFor({ collectionDir, resourceId });
1649
+ await Promise.all(existing
1650
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
1651
+ .map(name => rm(name)));
1652
+ // Maintain the server-managed timestamps and the monotonic `version`: a
1653
+ // content write sets `createdAt` on first write, bumps `updatedAt`, and
1654
+ // increments `version` (the ETag validator) from its prior value, preserving
1655
+ // any user-writable `custom` and the independent `metaVersion` already stored
1656
+ // in the sidecar (a content write does not touch the metadata sub-resource).
1657
+ //
1658
+ // `createdBy` pairs with `createdAt`: taken from this write's invoker only
1659
+ // when this write creates the sidecar, so it names the creator rather than
1660
+ // the last writer, and is preserved verbatim afterward -- including
1661
+ // preserved-as-absent, so a Resource created with no invoker never has a
1662
+ // later writer backfilled into it. A tombstone keeps both, so re-creating a
1663
+ // deleted id under a different invoker preserves the original creator, as
1664
+ // it does the original `createdAt`.
1665
+ const now = new Date().toISOString();
1666
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId });
1667
+ const version = (prior?.version ?? 0) + 1;
1668
+ const creator = prior ? prior.createdBy : createdBy;
1669
+ await this._writeMetaSidecar({
1670
+ collectionDir,
1671
+ resourceId,
1672
+ sidecar: {
1673
+ createdAt: prior?.createdAt ?? now,
1674
+ updatedAt: now,
1675
+ ...(creator !== undefined && { createdBy: creator }),
1676
+ version,
1677
+ ...(prior?.metaVersion !== undefined && {
1678
+ metaVersion: prior.metaVersion
1679
+ }),
1680
+ ...(prior?.custom && { custom: prior.custom }),
1681
+ // The key-epoch stamp is set from this write's declaration and CLEARED
1682
+ // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
1683
+ // worse than none), so it is NOT preserved from `prior` like `custom`.
1684
+ ...(epoch !== undefined && { epoch })
1685
+ }
1686
+ });
1687
+ return { version };
1688
+ }
1689
+ /**
1690
+ * Writes a representation body (JSON value or byte stream) to `filePath`,
1691
+ * applying the same size guards every write path shares: the per-upload cap
1692
+ * (413 `PayloadTooLargeError`) and, when a byte quota is configured, the
1693
+ * Space headroom (507). A JSON body is fully in memory, so its size is checked
1694
+ * up front and written atomically; a binary body is streamed through the cap /
1695
+ * quota guards into a temp file and durably committed (fsync + rename + dir
1696
+ * fsync), removing the partial file on any failure. Shared by the Resource
1697
+ * write path (`_writeResourceLocked`) and the chunk write path
1698
+ * (`_writeChunkLocked`); the caller has already resolved `filePath` and ensured
1699
+ * its parent directory exists for the streamed case.
1700
+ * @param options {object}
1701
+ * @param options.spaceId {string}
1702
+ * @param options.filePath {string} absolute path of the representation file
1703
+ * @param options.input {ResourceInput}
1704
+ * @returns {Promise<void>}
1705
+ */
1706
+ async _writeRepresentationBytes({ spaceId, filePath, input }) {
1518
1707
  const { capacityBytes, maxUploadBytes } = this;
1519
1708
  if (input.kind === 'json') {
1520
1709
  // JSON bodies are fully in memory, so their serialized size is known up
@@ -1603,50 +1792,6 @@ export class FileSystemBackend {
1603
1792
  throw err;
1604
1793
  }
1605
1794
  }
1606
- // A Resource has a single current representation: remove any prior
1607
- // representation stored under a different content-type (its filename
1608
- // differs). Write-new-then-prune (not delete-then-write) so the resource is
1609
- // never momentarily absent.
1610
- const existing = await this._resourceFilesFor({ collectionDir, resourceId });
1611
- await Promise.all(existing
1612
- .filter(name => path.resolve(name) !== path.resolve(filePath))
1613
- .map(name => rm(name)));
1614
- // Maintain the server-managed timestamps and the monotonic `version`: a
1615
- // content write sets `createdAt` on first write, bumps `updatedAt`, and
1616
- // increments `version` (the ETag validator) from its prior value, preserving
1617
- // any user-writable `custom` and the independent `metaVersion` already stored
1618
- // in the sidecar (a content write does not touch the metadata sub-resource).
1619
- //
1620
- // `createdBy` pairs with `createdAt`: taken from this write's invoker only
1621
- // when this write creates the sidecar, so it names the creator rather than
1622
- // the last writer, and is preserved verbatim afterward -- including
1623
- // preserved-as-absent, so a Resource created with no invoker never has a
1624
- // later writer backfilled into it. A tombstone keeps both, so re-creating a
1625
- // deleted id under a different invoker preserves the original creator, as
1626
- // it does the original `createdAt`.
1627
- const now = new Date().toISOString();
1628
- const prior = await this._readMetaSidecar({ collectionDir, resourceId });
1629
- const version = (prior?.version ?? 0) + 1;
1630
- const creator = prior ? prior.createdBy : createdBy;
1631
- await this._writeMetaSidecar({
1632
- collectionDir,
1633
- resourceId,
1634
- sidecar: {
1635
- createdAt: prior?.createdAt ?? now,
1636
- updatedAt: now,
1637
- ...(creator !== undefined && { createdBy: creator }),
1638
- version,
1639
- ...(prior?.metaVersion !== undefined && {
1640
- metaVersion: prior.metaVersion
1641
- }),
1642
- ...(prior?.custom && { custom: prior.custom }),
1643
- // The key-epoch stamp is set from this write's declaration and CLEARED
1644
- // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
1645
- // worse than none), so it is NOT preserved from `prior` like `custom`.
1646
- ...(epoch !== undefined && { epoch })
1647
- }
1648
- });
1649
- return { version };
1650
1795
  }
1651
1796
  /**
1652
1797
  * Builds the per-Resource serialization key for `_writeMutex`
@@ -1865,7 +2010,7 @@ export class FileSystemBackend {
1865
2010
  * @returns {Promise<{ metaVersion: number } | undefined>} the new
1866
2011
  * `metaVersion`, or `undefined` when the Resource does not exist
1867
2012
  */
1868
- async writeResourceMetadata({ spaceId, collectionId, resourceId, custom, epoch, ifMatch, ifNoneMatch }) {
2013
+ async writeResourceMetadata({ spaceId, collectionId, resourceId, custom, epoch, uniqueIndexes, ifMatch, ifNoneMatch }) {
1869
2014
  const collectionDir = this._collectionDir({ spaceId, collectionId });
1870
2015
  const writeMeta = async () => {
1871
2016
  const filePath = await this._findFile({ collectionDir, resourceId });
@@ -1922,6 +2067,32 @@ export class FileSystemBackend {
1922
2067
  });
1923
2068
  return { metaVersion };
1924
2069
  };
2070
+ // A metadata write can create a plaintext equality unique claim for a
2071
+ // `custom`-sourced attribute (the `equality-query` feature). When the
2072
+ // Collection declares any unique index, serialize on the Collection lock and
2073
+ // scan for a conflict -- content is the Resource's stored JSON content
2074
+ // (unchanged by a metadata write), custom is the incoming value this write
2075
+ // sets -- before taking the per-Resource lock nested inside. Distinct-key
2076
+ // nesting cannot deadlock (plain writes never hold a Resource key while
2077
+ // waiting on a Collection key).
2078
+ if (uniqueIndexes !== undefined && uniqueIndexes.length > 0) {
2079
+ return this._writeMutex.run(this._collectionLockKey({ spaceId, collectionId }), async () => {
2080
+ assertNoUniqueEqualityConflict({
2081
+ indexes: uniqueIndexes,
2082
+ content: await this._readResourceJsonContent({
2083
+ collectionDir,
2084
+ resourceId
2085
+ }),
2086
+ custom,
2087
+ candidates: await this._readEqualityCandidates({
2088
+ spaceId,
2089
+ collectionId,
2090
+ excludeResourceId: resourceId
2091
+ })
2092
+ });
2093
+ return this._writeMutex.run(this._resourceLockKey({ spaceId, collectionId, resourceId }), writeMeta);
2094
+ });
2095
+ }
1925
2096
  return this._writeMutex.run(this._resourceLockKey({ spaceId, collectionId, resourceId }), writeMeta);
1926
2097
  }
1927
2098
  /**
@@ -1979,6 +2150,15 @@ export class FileSystemBackend {
1979
2150
  const { contentType } = parseResourceFileName(path.basename(filesForResource[0]));
1980
2151
  // Drop the content representation(s) but KEEP the sidecar as the tombstone.
1981
2152
  await Promise.all(filesForResource.map(filename => rm(filename)));
2153
+ // Cascade-delete the Resource's chunks (the `chunked-streams` feature): a
2154
+ // chunk must never outlive its parent Resource, so its whole chunk
2155
+ // directory goes with the content. Runs under the same per-Resource lock a
2156
+ // `writeChunk` takes, so a chunk write racing this delete cannot re-create
2157
+ // an orphan directory. `force` makes an absent chunk directory a no-op.
2158
+ await rm(this._chunkDir({ collectionDir, resourceId }), {
2159
+ recursive: true,
2160
+ force: true
2161
+ });
1982
2162
  // Bump `version` / `updatedAt` so the tombstone sorts after the Resource's
1983
2163
  // prior state in the change feed, and continues the monotonic version (a
1984
2164
  // later re-create reads this sidecar and keeps counting up). `custom` is
@@ -2007,6 +2187,315 @@ export class FileSystemBackend {
2007
2187
  // for a conditional delete, as the old unconditional removal did).
2008
2188
  return this._writeMutex.run(this._resourceLockKey({ spaceId, collectionId, resourceId }), softDelete);
2009
2189
  }
2190
+ // Chunks (the `chunked-streams` feature)
2191
+ /**
2192
+ * Builds the on-disk path for a Resource's chunk directory
2193
+ * (`.chunks.<encodedResourceId>/`) inside its Collection dir. A hidden
2194
+ * subdirectory (leading `.`, so it is invisible to the `r.`-prefixed Collection
2195
+ * listing and the live-Resource count) that holds the Resource's chunk
2196
+ * representations; inside it a chunk is stored exactly like a Resource keyed by
2197
+ * its index (`r.<index>.<encodedContentType>.<ext>` plus a `.meta.<index>.json`
2198
+ * version sidecar), so the Resource file / sidecar helpers are reused verbatim
2199
+ * with the chunk directory as the `collectionDir`.
2200
+ * @param options {object}
2201
+ * @param options.collectionDir {string}
2202
+ * @param options.resourceId {string}
2203
+ * @returns {string}
2204
+ */
2205
+ _chunkDir({ collectionDir, resourceId }) {
2206
+ const chunkDir = path.join(collectionDir, chunkDirName(resourceId));
2207
+ this._assertContained(chunkDir);
2208
+ return chunkDir;
2209
+ }
2210
+ /**
2211
+ * Writes one chunk of a chunked Resource, keyed by
2212
+ * `(spaceId, collectionId, resourceId, chunkIndex)`. The parent Resource MUST
2213
+ * already exist (else `ResourceNotFoundError`, 404), checked under the same
2214
+ * per-Resource lock a `deleteResource` cascade takes so a chunk can never be
2215
+ * orphaned by a racing delete. The body is stored opaquely (bytes +
2216
+ * content-type) through the shared upload-cap / quota guards, and the chunk's
2217
+ * own monotonic `version` (its ETag validator, independent of the parent's) is
2218
+ * bumped; any `If-Match` / `If-None-Match` precondition is evaluated on that
2219
+ * version atomically with the write (`PreconditionFailedError`, 412).
2220
+ * @param options {object}
2221
+ * @param options.spaceId {string}
2222
+ * @param options.collectionId {string}
2223
+ * @param options.resourceId {string}
2224
+ * @param options.chunkIndex {number} non-negative integer chunk position
2225
+ * @param options.input {ResourceInput}
2226
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
2227
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
2228
+ * @returns {Promise<{ version: number }>} the chunk's new version
2229
+ */
2230
+ async writeChunk({ spaceId, collectionId, resourceId, chunkIndex, input, ifMatch, ifNoneMatch }) {
2231
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2232
+ // Serialize on the parent Resource's lock key -- the same key
2233
+ // `deleteResource` takes -- so the parent-exists check, the write, and the
2234
+ // cascade delete cannot interleave (no orphan chunk).
2235
+ return this._writeMutex.run(this._resourceLockKey({ spaceId, collectionId, resourceId }), () => this._writeChunkLocked({
2236
+ spaceId,
2237
+ collectionDir,
2238
+ resourceId,
2239
+ chunkIndex,
2240
+ input,
2241
+ ifMatch,
2242
+ ifNoneMatch
2243
+ }));
2244
+ }
2245
+ /**
2246
+ * The critical section of `writeChunk`, run under the per-Resource lock. See
2247
+ * `writeChunk` for the parameters.
2248
+ * @returns {Promise<{ version: number }>}
2249
+ */
2250
+ async _writeChunkLocked({ spaceId, collectionDir, resourceId, chunkIndex, input, ifMatch, ifNoneMatch }) {
2251
+ // The parent Resource must exist: writing a chunk of an absent Resource
2252
+ // rejects, so orphan chunks cannot accumulate.
2253
+ const parentExists = (await this._findFile({ collectionDir, resourceId })) !== undefined;
2254
+ if (!parentExists) {
2255
+ throw new ResourceNotFoundError({ requestName: 'Write Chunk' });
2256
+ }
2257
+ // Inside the chunk directory a chunk is a Resource keyed by its index, so the
2258
+ // Resource file / sidecar helpers apply with `chunkDir` as the collectionDir
2259
+ // and the stringified index as the resourceId.
2260
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
2261
+ const chunkId = String(chunkIndex);
2262
+ const filename = fileNameFor({
2263
+ resourceId: chunkId,
2264
+ contentType: input.contentType
2265
+ });
2266
+ const filePath = path.join(chunkDir, filename);
2267
+ this._assertContained(filePath);
2268
+ // Evaluate any precondition against the chunk's current version before
2269
+ // writing (still inside the lock, so check and write are atomic).
2270
+ if (ifMatch !== undefined || ifNoneMatch) {
2271
+ await this._assertWritePrecondition({
2272
+ collectionDir: chunkDir,
2273
+ resourceId: chunkId,
2274
+ ifMatch,
2275
+ ifNoneMatch
2276
+ });
2277
+ }
2278
+ // The chunk directory is created lazily on first write (a binary body is
2279
+ // streamed into a temp file in it, so it must exist first).
2280
+ await mkdir(chunkDir, { recursive: true });
2281
+ await this._writeRepresentationBytes({ spaceId, filePath, input });
2282
+ // A chunk has a single current representation: remove any prior one stored
2283
+ // under a different content-type (write-new-then-prune).
2284
+ const existing = await this._resourceFilesFor({
2285
+ collectionDir: chunkDir,
2286
+ resourceId: chunkId
2287
+ });
2288
+ await Promise.all(existing
2289
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
2290
+ .map(name => rm(name)));
2291
+ // Bump the chunk's monotonic `version` (its ETag validator), preserving its
2292
+ // `createdAt`. A chunk carries no user Metadata / `createdBy` / epoch stamp.
2293
+ const now = new Date().toISOString();
2294
+ const prior = await this._readMetaSidecar({
2295
+ collectionDir: chunkDir,
2296
+ resourceId: chunkId
2297
+ });
2298
+ const version = (prior?.version ?? 0) + 1;
2299
+ await this._writeMetaSidecar({
2300
+ collectionDir: chunkDir,
2301
+ resourceId: chunkId,
2302
+ sidecar: {
2303
+ createdAt: prior?.createdAt ?? now,
2304
+ updatedAt: now,
2305
+ version
2306
+ }
2307
+ });
2308
+ return { version };
2309
+ }
2310
+ /**
2311
+ * Reads a chunk's bytes, resolving a `ResourceResult` (stream + resolved
2312
+ * content-type + the chunk's `version`). Throws `ResourceNotFoundError` (404)
2313
+ * when the chunk is absent.
2314
+ * @param options {object}
2315
+ * @param options.spaceId {string}
2316
+ * @param options.collectionId {string}
2317
+ * @param options.resourceId {string}
2318
+ * @param options.chunkIndex {number}
2319
+ * @returns {Promise<ResourceResult>}
2320
+ */
2321
+ async getChunk({ spaceId, collectionId, resourceId, chunkIndex }) {
2322
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2323
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
2324
+ const chunkId = String(chunkIndex);
2325
+ const filePath = await this._findFile({
2326
+ collectionDir: chunkDir,
2327
+ resourceId: chunkId
2328
+ });
2329
+ if (!filePath) {
2330
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' });
2331
+ }
2332
+ try {
2333
+ await fsStat(filePath);
2334
+ }
2335
+ catch (err) {
2336
+ if (err.code === 'ENOENT') {
2337
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' });
2338
+ }
2339
+ throw err;
2340
+ }
2341
+ const { contentType: storedResourceType } = parseResourceFileName(path.basename(filePath));
2342
+ const sidecar = await this._readMetaSidecar({
2343
+ collectionDir: chunkDir,
2344
+ resourceId: chunkId
2345
+ });
2346
+ return {
2347
+ resourceStream: await openFileStream(filePath, this.logger),
2348
+ storedResourceType,
2349
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
2350
+ };
2351
+ }
2352
+ /**
2353
+ * Reads a chunk's stored content-type / size / version (the HEAD payload
2354
+ * headers). Resolves `undefined` when the chunk is absent.
2355
+ * @param options {object}
2356
+ * @param options.spaceId {string}
2357
+ * @param options.collectionId {string}
2358
+ * @param options.resourceId {string}
2359
+ * @param options.chunkIndex {number}
2360
+ * @returns {Promise<{ contentType: string, size: number, version?: number } |
2361
+ * undefined>}
2362
+ */
2363
+ async getChunkMetadata({ spaceId, collectionId, resourceId, chunkIndex }) {
2364
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2365
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
2366
+ const chunkId = String(chunkIndex);
2367
+ const filePath = await this._findFile({
2368
+ collectionDir: chunkDir,
2369
+ resourceId: chunkId
2370
+ });
2371
+ if (!filePath) {
2372
+ return undefined;
2373
+ }
2374
+ let stats;
2375
+ try {
2376
+ stats = await fsStat(filePath);
2377
+ }
2378
+ catch (err) {
2379
+ if (err.code === 'ENOENT') {
2380
+ return undefined;
2381
+ }
2382
+ throw err;
2383
+ }
2384
+ const { contentType } = parseResourceFileName(path.basename(filePath));
2385
+ const sidecar = await this._readMetaSidecar({
2386
+ collectionDir: chunkDir,
2387
+ resourceId: chunkId
2388
+ });
2389
+ return {
2390
+ contentType,
2391
+ size: stats.size,
2392
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
2393
+ };
2394
+ }
2395
+ /**
2396
+ * Deletes one chunk (a hard delete: its bytes and version sidecar both go, and
2397
+ * -- unlike a Resource -- it leaves no tombstone, since chunks are not part of
2398
+ * the change feed). Resolves `true` when a chunk was removed and `false` when
2399
+ * none was stored at that index. When `ifMatch` is supplied it is evaluated on
2400
+ * the chunk's current version atomically with the removal (under the same
2401
+ * per-Resource lock), throwing `PreconditionFailedError` (412) on a mismatch.
2402
+ * @param options {object}
2403
+ * @param options.spaceId {string}
2404
+ * @param options.collectionId {string}
2405
+ * @param options.resourceId {string}
2406
+ * @param options.chunkIndex {number}
2407
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
2408
+ * @returns {Promise<boolean>}
2409
+ */
2410
+ async deleteChunk({ spaceId, collectionId, resourceId, chunkIndex, ifMatch }) {
2411
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2412
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
2413
+ const chunkId = String(chunkIndex);
2414
+ return this._writeMutex.run(this._resourceLockKey({ spaceId, collectionId, resourceId }), async () => {
2415
+ const files = await this._resourceFilesFor({
2416
+ collectionDir: chunkDir,
2417
+ resourceId: chunkId
2418
+ });
2419
+ if (files.length === 0) {
2420
+ // Absent: the handler 404s on `false` (chunk deletes are not silently
2421
+ // idempotent, mirroring the EDV chunk contract).
2422
+ return false;
2423
+ }
2424
+ if (ifMatch !== undefined) {
2425
+ await this._assertWritePrecondition({
2426
+ collectionDir: chunkDir,
2427
+ resourceId: chunkId,
2428
+ ifMatch
2429
+ });
2430
+ }
2431
+ await Promise.all(files.map(name => rm(name)));
2432
+ // Remove the version sidecar too: a chunk keeps no tombstone.
2433
+ await rm(this._metaSidecarPath({
2434
+ collectionDir: chunkDir,
2435
+ resourceId: chunkId
2436
+ }), { force: true });
2437
+ // When that was the last chunk, remove the now-empty chunk directory
2438
+ // itself: a lingering empty `.chunks.<encId>/` would otherwise appear
2439
+ // in the export walk (diverging from a Postgres export of the same
2440
+ // logical state) and count its allocated block toward the du-based
2441
+ // quota measurement. Safe under the per-Resource lock (`writeChunk`
2442
+ // serializes on the same key, so nothing lands in the directory
2443
+ // between the check and the rmdir).
2444
+ const remaining = await fs.promises.readdir(chunkDir);
2445
+ if (remaining.length === 0) {
2446
+ await fs.promises.rmdir(chunkDir);
2447
+ }
2448
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
2449
+ this._usageCache.delete(spaceId);
2450
+ return true;
2451
+ });
2452
+ }
2453
+ /**
2454
+ * Lists a Resource's stored chunks in ascending `index` order -- the
2455
+ * discovery/reassembly listing (the server never reassembles). Resolves an
2456
+ * empty listing when the Resource has no chunk directory (including when the
2457
+ * Resource itself is absent -- existence is the parent routes' concern).
2458
+ * @param options {object}
2459
+ * @param options.spaceId {string}
2460
+ * @param options.collectionId {string}
2461
+ * @param options.resourceId {string}
2462
+ * @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
2463
+ * number, contentType: string, version?: number }> }>}
2464
+ */
2465
+ async listChunks({ spaceId, collectionId, resourceId }) {
2466
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2467
+ const chunkDir = this._chunkDir({ collectionDir, resourceId });
2468
+ let entries;
2469
+ try {
2470
+ entries = await fs.promises.readdir(chunkDir, { withFileTypes: true });
2471
+ }
2472
+ catch (err) {
2473
+ if (err.code === 'ENOENT') {
2474
+ return { count: 0, chunks: [] };
2475
+ }
2476
+ throw err;
2477
+ }
2478
+ // Keep only chunk representations (`r.<index>.<type>.<ext>`), dropping the
2479
+ // `.meta.<index>.json` version sidecars.
2480
+ const chunkEntries = entries.filter(entry => entry.isFile() && entry.name.startsWith('r.'));
2481
+ const chunks = await Promise.all(chunkEntries.map(async (entry) => {
2482
+ const { resourceId: indexStr, contentType } = parseResourceFileName(entry.name);
2483
+ const filePath = path.join(chunkDir, entry.name);
2484
+ const stats = await fsStat(filePath);
2485
+ const sidecar = await this._readMetaSidecar({
2486
+ collectionDir: chunkDir,
2487
+ resourceId: indexStr
2488
+ });
2489
+ return {
2490
+ index: Number(indexStr),
2491
+ size: stats.size,
2492
+ contentType,
2493
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
2494
+ };
2495
+ }));
2496
+ chunks.sort((left, right) => left.index - right.index);
2497
+ return { count: chunks.length, chunks };
2498
+ }
2010
2499
  /**
2011
2500
  * Replication change feed (the `changes` query profile; see the
2012
2501
  * `StorageBackend.changesSince` contract.
@@ -2259,6 +2748,147 @@ export class FileSystemBackend {
2259
2748
  }
2260
2749
  }))).filter(candidate => candidate !== undefined);
2261
2750
  }
2751
+ /**
2752
+ * Plaintext equality query (the `equality` query profile; see the
2753
+ * `StorageBackend.queryByEquality` contract). Reads every live Resource of
2754
+ * the Collection -- JSON Resources carrying parsed `content`, blobs carrying
2755
+ * only their sidecar `custom` -- and hands the candidates to the shared
2756
+ * evaluator (`lib/equalityIndex.ts`) for extraction, matching, ordering, and
2757
+ * cursor pagination. O(n) over the Collection per call, with every JSON body
2758
+ * read -- deliberate for this teaching backend; a materialized backend would
2759
+ * answer from an attribute index. Tombstones are excluded naturally (no live
2760
+ * content file).
2761
+ * @param options {object}
2762
+ * @param options.spaceId {string}
2763
+ * @param options.collectionId {string}
2764
+ * @param options.query {EqualityQuery}
2765
+ * @param options.indexes {NormalizedIndexDeclaration[]} the normalized
2766
+ * declared indexes (the request layer resolves them from the description)
2767
+ * @param [options.count] {boolean} return only the match count
2768
+ * @param [options.limit] {number} requested page size
2769
+ * @param [options.cursor] {string} opaque cursor from a prior page
2770
+ * @returns {Promise<{ count: number } | EqualityQueryPage>}
2771
+ */
2772
+ async queryByEquality({ spaceId, collectionId, query, indexes, count, limit, cursor }) {
2773
+ const candidates = await this._readEqualityCandidates({
2774
+ spaceId,
2775
+ collectionId
2776
+ });
2777
+ return runEqualityQuery({
2778
+ candidates,
2779
+ query,
2780
+ indexes,
2781
+ count,
2782
+ limit,
2783
+ cursor
2784
+ });
2785
+ }
2786
+ /**
2787
+ * Declare-time uniqueness scan for the `equality` profile (see the
2788
+ * `StorageBackend.findEqualityUniqueViolation` contract): reads the
2789
+ * Collection's live Resources and delegates to the shared scan, which reports
2790
+ * the first `(name, value)` claimed by two different Resources under the given
2791
+ * `unique` declarations (or `undefined` when none is).
2792
+ * @param options {object}
2793
+ * @param options.spaceId {string}
2794
+ * @param options.collectionId {string}
2795
+ * @param options.indexes {NormalizedIndexDeclaration[]}
2796
+ * @returns {Promise<{ name: string, value: EqualityValue } | undefined>}
2797
+ */
2798
+ async findEqualityUniqueViolation({ spaceId, collectionId, indexes }) {
2799
+ const candidates = await this._readEqualityCandidates({
2800
+ spaceId,
2801
+ collectionId
2802
+ });
2803
+ return findEqualityUniqueViolation({ indexes, candidates });
2804
+ }
2805
+ /**
2806
+ * Reads every live Resource of a Collection as an equality candidate -- the
2807
+ * candidate set for the equality query and the plaintext unique-attribute
2808
+ * conflict scans. Unlike `_readJsonCandidates` this INCLUDES blob Resources
2809
+ * (a blob is queryable through its `custom`-sourced attributes): each entry
2810
+ * resolves `{ resourceId, content?, custom? }`, where `content` is the parsed
2811
+ * JSON of a JSON-typed representation (the blob content read is skipped, and
2812
+ * unparsable JSON is dropped, as in `_readJsonCandidates`) and `custom` is the
2813
+ * `.meta.` sidecar's `custom` when present. Tombstones are excluded naturally
2814
+ * (no live `r.` content file); an optional excluded Resource is skipped. An
2815
+ * absent Collection dir resolves empty.
2816
+ * @param options {object}
2817
+ * @param options.spaceId {string}
2818
+ * @param options.collectionId {string}
2819
+ * @param [options.excludeResourceId] {string} omit this Resource (a conflict
2820
+ * scan excludes the Resource being written)
2821
+ * @returns {Promise<EqualityCandidate[]>}
2822
+ */
2823
+ async _readEqualityCandidates({ spaceId, collectionId, excludeResourceId }) {
2824
+ const collectionDir = this._collectionDir({ spaceId, collectionId });
2825
+ let entries = [];
2826
+ try {
2827
+ entries = await fs.promises.readdir(collectionDir, {
2828
+ withFileTypes: true
2829
+ });
2830
+ }
2831
+ catch (err) {
2832
+ if (err.code !== 'ENOENT') {
2833
+ throw err;
2834
+ }
2835
+ }
2836
+ return await Promise.all(entries
2837
+ .filter(entry => entry.isFile() && entry.name.startsWith('r.'))
2838
+ .map(entry => ({
2839
+ fileName: entry.name,
2840
+ ...parseResourceFileName(entry.name)
2841
+ }))
2842
+ .filter(({ resourceId }) => resourceId !== excludeResourceId)
2843
+ .map(async ({ resourceId, fileName, contentType }) => {
2844
+ // Parse the content only for a JSON representation; a blob contributes
2845
+ // no content-sourced attributes (its `custom` still makes it
2846
+ // queryable). Unparsable JSON is treated as no content.
2847
+ let content;
2848
+ if (isJson({ contentType })) {
2849
+ try {
2850
+ content = JSON.parse(await fs.promises.readFile(path.join(collectionDir, fileName), 'utf8'));
2851
+ }
2852
+ catch {
2853
+ content = undefined;
2854
+ }
2855
+ }
2856
+ const sidecar = await this._readMetaSidecar({
2857
+ collectionDir,
2858
+ resourceId
2859
+ });
2860
+ return {
2861
+ resourceId,
2862
+ ...(content !== undefined && { content }),
2863
+ ...(sidecar?.custom !== undefined && { custom: sidecar.custom })
2864
+ };
2865
+ }));
2866
+ }
2867
+ /**
2868
+ * Reads and parses a single Resource's stored JSON content, or resolves
2869
+ * `undefined` when the Resource is absent, a blob, or unparsable JSON -- the
2870
+ * content side of a custom-sourced unique-attribute claim on a metadata write.
2871
+ * @param options {object}
2872
+ * @param options.collectionDir {string}
2873
+ * @param options.resourceId {string}
2874
+ * @returns {Promise<unknown>}
2875
+ */
2876
+ async _readResourceJsonContent({ collectionDir, resourceId }) {
2877
+ const filePath = await this._findFile({ collectionDir, resourceId });
2878
+ if (!filePath) {
2879
+ return undefined;
2880
+ }
2881
+ const { contentType } = parseResourceFileName(path.basename(filePath));
2882
+ if (!isJson({ contentType })) {
2883
+ return undefined;
2884
+ }
2885
+ try {
2886
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8'));
2887
+ }
2888
+ catch {
2889
+ return undefined;
2890
+ }
2891
+ }
2262
2892
  // Policies
2263
2893
  /**
2264
2894
  * Builds the on-disk path for a policy document. Stored as a dot-file keyed by