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
@@ -44,7 +44,9 @@ import { collectionPath, resourcePath } from '../lib/paths.js'
44
44
  import {
45
45
  encodeFilenameSegment,
46
46
  fileNameFor,
47
- parseResourceFileName
47
+ parseResourceFileName,
48
+ chunkDirName,
49
+ CHUNK_DIR_PREFIX
48
50
  } from '../lib/resourceFileName.js'
49
51
  import { sanitizeBackendRecord } from '../lib/backends.js'
50
52
  import { backendUsageFields } from '../lib/backendUsage.js'
@@ -74,6 +76,18 @@ import type {
74
76
  BlindedIndexQuery,
75
77
  BlindedIndexQueryPage
76
78
  } from '../lib/blindedIndex.js'
79
+ import {
80
+ runEqualityQuery,
81
+ assertNoUniqueEqualityConflict,
82
+ findEqualityUniqueViolation
83
+ } from '../lib/equalityIndex.js'
84
+ import type {
85
+ EqualityQuery,
86
+ EqualityQueryPage,
87
+ EqualityCandidate,
88
+ EqualityValue,
89
+ NormalizedIndexDeclaration
90
+ } from '../lib/equalityIndex.js'
77
91
  import {
78
92
  assertWritePrecondition,
79
93
  assertMetaWritePrecondition,
@@ -373,8 +387,9 @@ export class FileSystemBackend implements StorageBackend {
373
387
  * It advertises the `conditional-writes` affordance: it exposes a per-Resource
374
388
  * `version` as an HTTP `ETag` validator and honors `If-Match` / `If-None-Match`
375
389
  * write preconditions atomically (returning `412 precondition-failed` on a
376
- * mismatch). The remaining `features` vocabulary token (`chunked-streams`)
377
- * is not implemented yet and is added when it lands.
390
+ * mismatch). It also advertises `chunked-streams`: chunk addressing for large
391
+ * Resources (`/{resourceId}/chunks/{n}`), each chunk stored opaquely like a
392
+ * binary Resource representation.
378
393
  * (Client-side encryption is deliberately not a backend feature: encrypted
379
394
  * documents are opaque client-encrypted JSON this backend already stores
380
395
  * faithfully, with no server cooperation.)
@@ -399,11 +414,18 @@ export class FileSystemBackend implements StorageBackend {
399
414
  // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
400
415
  // keys on the `encryption` marker, a client-declared `epoch` stamp on
401
416
  // Resources, and conditional (`If-Match`) Collection Description writes.
417
+ // `chunked-streams`: chunk addressing (`/{resourceId}/chunks/{n}`) for a
418
+ // large Resource, each chunk stored opaquely (bytes + content-type).
419
+ // `equality-query`: serves the `equality` profile -- server-extracted
420
+ // plaintext attribute equality over a Collection's declared `indexes`
421
+ // (`queryByEquality`), plus the GET `filter[attr]=value` equality filter.
402
422
  features: [
403
423
  'conditional-writes',
404
424
  'changes-query',
405
425
  'blinded-index-query',
406
- 'key-epochs'
426
+ 'equality-query',
427
+ 'key-epochs',
428
+ 'chunked-streams'
407
429
  ]
408
430
  }
409
431
  }
@@ -1096,6 +1118,14 @@ export class FileSystemBackend implements StorageBackend {
1096
1118
  spaceEntries.sort((a, b) => a.name.localeCompare(b.name))
1097
1119
 
1098
1120
  const collectionEntriesByDir: Record<string, typeof spaceEntries> = {}
1121
+ // Per-Resource chunk directories (`.chunks.<encId>/`; the `chunked-streams`
1122
+ // feature) are subdirectories of a Collection dir, so the file filter below
1123
+ // skips them. Gather each one's files here so a chunked Resource's chunks
1124
+ // travel in the export, keyed by Collection then by chunk-directory name.
1125
+ const chunkDirsByCollection: Record<
1126
+ string,
1127
+ Array<{ dirName: string; files: string[] }>
1128
+ > = {}
1099
1129
  for (const entry of spaceEntries) {
1100
1130
  if (!entry.isDirectory()) {
1101
1131
  continue
@@ -1107,6 +1137,22 @@ export class FileSystemBackend implements StorageBackend {
1107
1137
  collectionEntriesByDir[entry.name] = entries
1108
1138
  .filter(e => e.isFile())
1109
1139
  .sort((a, b) => a.name.localeCompare(b.name))
1140
+ const chunkDirs: Array<{ dirName: string; files: string[] }> = []
1141
+ for (const sub of entries
1142
+ .filter(e => e.isDirectory() && e.name.startsWith(CHUNK_DIR_PREFIX))
1143
+ .sort((a, b) => a.name.localeCompare(b.name))) {
1144
+ const files = (
1145
+ await fs.promises.readdir(
1146
+ path.join(sourceSpaceDir, entry.name, sub.name),
1147
+ { withFileTypes: true }
1148
+ )
1149
+ )
1150
+ .filter(e => e.isFile())
1151
+ .map(e => e.name)
1152
+ .sort((a, b) => a.localeCompare(b))
1153
+ chunkDirs.push({ dirName: sub.name, files })
1154
+ }
1155
+ chunkDirsByCollection[entry.name] = chunkDirs
1110
1156
  }
1111
1157
 
1112
1158
  // Space-scoped zcap revocations travel with the export. They live in the
@@ -1135,9 +1181,18 @@ export class FileSystemBackend implements StorageBackend {
1135
1181
  entry.isDirectory()
1136
1182
  ? {
1137
1183
  name: entry.name,
1138
- files: (collectionEntriesByDir[entry.name] ?? []).map(
1139
- file => file.name
1140
- )
1184
+ // The Collection's own files, then each chunked Resource's chunk
1185
+ // files listed by their `.chunks.<encId>/<file>` relative path
1186
+ // (the manifest mirrors the pack order below).
1187
+ files: [
1188
+ ...(collectionEntriesByDir[entry.name] ?? []).map(
1189
+ file => file.name
1190
+ ),
1191
+ ...(chunkDirsByCollection[entry.name] ?? []).flatMap(
1192
+ ({ dirName, files }) =>
1193
+ files.map(file => `${dirName}/${file}`)
1194
+ )
1195
+ ]
1141
1196
  }
1142
1197
  : // top-level files in space (e.g. .space.<spaceId>.json)
1143
1198
  { name: entry.name }
@@ -1164,6 +1219,25 @@ export class FileSystemBackend implements StorageBackend {
1164
1219
  )
1165
1220
  pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes)
1166
1221
  }
1222
+ // A chunked Resource's chunk directory and its files, packed under the
1223
+ // Collection so `.chunks.<encId>/<file>` round-trips on import.
1224
+ for (const { dirName, files } of chunkDirsByCollection[entry.name] ??
1225
+ []) {
1226
+ pack.entry({
1227
+ name: `${entryTarget}/${dirName}/`,
1228
+ type: 'directory',
1229
+ mtime
1230
+ })
1231
+ for (const file of files) {
1232
+ const bytes = await fs.promises.readFile(
1233
+ path.join(sourceSpaceDir, entry.name, dirName, file)
1234
+ )
1235
+ pack.entry(
1236
+ { name: `${entryTarget}/${dirName}/${file}`, mtime },
1237
+ bytes
1238
+ )
1239
+ }
1240
+ }
1167
1241
  } else if (entry.isFile()) {
1168
1242
  const bytes = await fs.promises.readFile(
1169
1243
  path.join(sourceSpaceDir, entry.name)
@@ -1226,7 +1300,8 @@ export class FileSystemBackend implements StorageBackend {
1226
1300
  for (const {
1227
1301
  collectionId,
1228
1302
  collectionDescription,
1229
- resources
1303
+ resources,
1304
+ chunkFiles
1230
1305
  } of collections) {
1231
1306
  const existing = await this.getCollectionDescription({
1232
1307
  spaceId,
@@ -1259,6 +1334,19 @@ export class FileSystemBackend implements StorageBackend {
1259
1334
  }
1260
1335
  incomingBytes += body.length
1261
1336
  }
1337
+ // Chunk files (the `chunked-streams` feature) inherit the per-upload cap
1338
+ // and quota estimate, but NOT the encryption-conformance check: a chunk is
1339
+ // opaque bytes, not a JSON envelope of the Collection's scheme.
1340
+ for (const { body } of chunkFiles) {
1341
+ if (maxUploadBytes !== undefined && body.length > maxUploadBytes) {
1342
+ throw new PayloadTooLargeError({
1343
+ maxUploadBytes,
1344
+ backendId: this.describe().id,
1345
+ uploadBytes: body.length
1346
+ })
1347
+ }
1348
+ incomingBytes += body.length
1349
+ }
1262
1350
  }
1263
1351
  if (capacityBytes !== undefined) {
1264
1352
  await this._assertSpaceHeadroom({
@@ -1309,7 +1397,8 @@ export class FileSystemBackend implements StorageBackend {
1309
1397
  collectionPolicy,
1310
1398
  resources,
1311
1399
  resourcePolicies,
1312
- resourceMetadata
1400
+ resourceMetadata,
1401
+ chunkFiles
1313
1402
  } of collections) {
1314
1403
  // check if collection already exists
1315
1404
  const collectionExisted = Boolean(
@@ -1451,6 +1540,43 @@ export class FileSystemBackend implements StorageBackend {
1451
1540
  })
1452
1541
  stats.resourcesCreated++
1453
1542
  }
1543
+
1544
+ // Restore chunk files of chunked Resources (the `chunked-streams` feature)
1545
+ // into their per-Resource chunk directories. Skip-not-overwrite, per chunk
1546
+ // file: an existing chunk file is left untouched, so a re-import never
1547
+ // clobbers stored chunk bytes (or their version sidecar). An ORPHAN chunk
1548
+ // file -- one whose parent Resource is absent or a tombstone (no live
1549
+ // representation on the destination after the Resource apply loop above)
1550
+ // -- is skipped rather than resurrected, matching the live write path's
1551
+ // parent-exists rule (and the Postgres import).
1552
+ const parentIsLive = new Map<string, boolean>()
1553
+ for (const { resourceId, fileName, body } of chunkFiles) {
1554
+ let live = parentIsLive.get(resourceId)
1555
+ if (live === undefined) {
1556
+ live = Boolean(await this._findFile({ collectionDir, resourceId }))
1557
+ parentIsLive.set(resourceId, live)
1558
+ }
1559
+ if (!live) {
1560
+ continue
1561
+ }
1562
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
1563
+ const target = path.join(chunkDir, fileName)
1564
+ this._assertContained(target)
1565
+ let present = false
1566
+ try {
1567
+ await fsStat(target)
1568
+ present = true
1569
+ } catch (err) {
1570
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
1571
+ throw err
1572
+ }
1573
+ }
1574
+ if (present) {
1575
+ continue
1576
+ }
1577
+ await mkdir(chunkDir, { recursive: true })
1578
+ await atomicWriteFile({ filePath: target, data: body })
1579
+ }
1454
1580
  }
1455
1581
 
1456
1582
  // Restore the archive's Space-scoped zcap revocations under this Space's
@@ -1887,6 +2013,7 @@ export class FileSystemBackend implements StorageBackend {
1887
2013
  input,
1888
2014
  createdBy,
1889
2015
  epoch,
2016
+ uniqueIndexes,
1890
2017
  ifMatch,
1891
2018
  ifNoneMatch
1892
2019
  }: {
@@ -1896,6 +2023,7 @@ export class FileSystemBackend implements StorageBackend {
1896
2023
  input: ResourceInput
1897
2024
  createdBy?: IDID
1898
2025
  epoch?: string
2026
+ uniqueIndexes?: NormalizedIndexDeclaration[]
1899
2027
  ifMatch?: string
1900
2028
  ifNoneMatch?: boolean
1901
2029
  }): Promise<{ version: number }> {
@@ -1919,30 +2047,57 @@ export class FileSystemBackend implements StorageBackend {
1919
2047
  })
1920
2048
  )
1921
2049
 
1922
- // The EDV unique-attribute invariant (`unique: true` blinded attributes;
1923
- // the `blinded-index-query` feature): a write claiming a unique blinded
1924
- // triple must not collide with another live document's claim. Only a
1925
- // unique-carrying JSON write can create a new claim, so only such writes
1926
- // pay for it: they serialize per Collection (the outer lock, so two racing
1927
- // claimants cannot both pass the scan), evaluate the conflict against the
1928
- // Collection's other live JSON documents, then take the ordinary
1929
- // per-Resource lock nested inside. Distinct-key nesting cannot deadlock:
1930
- // plain writes never hold a Resource key while waiting on a Collection key.
1931
- if (
2050
+ // Two unique-attribute invariants can force a write to serialize on the
2051
+ // Collection lock before it takes its per-Resource lock: the EDV blinded
2052
+ // one (`unique: true` blinded attributes; the `blinded-index-query`
2053
+ // feature) and the plaintext equality one (a Collection's `unique`-declared
2054
+ // `indexes`; the `equality-query` feature). Only a JSON content write can
2055
+ // create either claim, so only such writes pay for it: they serialize per
2056
+ // Collection (the outer lock, so two racing claimants cannot both pass the
2057
+ // scan), evaluate the conflict against the Collection's other live
2058
+ // documents, then take the ordinary per-Resource lock nested inside.
2059
+ // Distinct-key nesting cannot deadlock: plain writes never hold a Resource
2060
+ // key while waiting on a Collection key. The two conditions are unified so a
2061
+ // write carrying both claims acquires the Collection lock exactly once.
2062
+ const blindedUnique =
1932
2063
  input.kind === 'json' &&
1933
2064
  collectUniqueBlindedTerms({ document: input.data }).length > 0
1934
- ) {
2065
+ const equalityUnique =
2066
+ input.kind === 'json' &&
2067
+ uniqueIndexes !== undefined &&
2068
+ uniqueIndexes.length > 0
2069
+ if (blindedUnique || equalityUnique) {
1935
2070
  return this._writeMutex.run(
1936
2071
  this._collectionLockKey({ spaceId, collectionId }),
1937
2072
  async () => {
1938
- assertNoUniqueBlindedConflict({
1939
- document: input.data,
1940
- candidates: await this._readJsonCandidates({
1941
- spaceId,
1942
- collectionId,
1943
- excludeResourceId: resourceId
2073
+ if (blindedUnique) {
2074
+ assertNoUniqueBlindedConflict({
2075
+ document: input.kind === 'json' ? input.data : undefined,
2076
+ candidates: await this._readJsonCandidates({
2077
+ spaceId,
2078
+ collectionId,
2079
+ excludeResourceId: resourceId
2080
+ })
1944
2081
  })
1945
- })
2082
+ }
2083
+ if (equalityUnique) {
2084
+ // A content write does not change the Resource's `custom`, so the
2085
+ // custom side of the claim comes from the CURRENT stored sidecar.
2086
+ const priorSidecar = await this._readMetaSidecar({
2087
+ collectionDir,
2088
+ resourceId
2089
+ })
2090
+ assertNoUniqueEqualityConflict({
2091
+ indexes: uniqueIndexes!,
2092
+ content: input.kind === 'json' ? input.data : undefined,
2093
+ custom: priorSidecar?.custom,
2094
+ candidates: await this._readEqualityCandidates({
2095
+ spaceId,
2096
+ collectionId,
2097
+ excludeResourceId: resourceId
2098
+ })
2099
+ })
2100
+ }
1946
2101
  return write()
1947
2102
  }
1948
2103
  )
@@ -2009,6 +2164,83 @@ export class FileSystemBackend implements StorageBackend {
2009
2164
  }
2010
2165
  }
2011
2166
 
2167
+ await this._writeRepresentationBytes({ spaceId, filePath, input })
2168
+
2169
+ // A Resource has a single current representation: remove any prior
2170
+ // representation stored under a different content-type (its filename
2171
+ // differs). Write-new-then-prune (not delete-then-write) so the resource is
2172
+ // never momentarily absent.
2173
+ const existing = await this._resourceFilesFor({ collectionDir, resourceId })
2174
+ await Promise.all(
2175
+ existing
2176
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
2177
+ .map(name => rm(name))
2178
+ )
2179
+
2180
+ // Maintain the server-managed timestamps and the monotonic `version`: a
2181
+ // content write sets `createdAt` on first write, bumps `updatedAt`, and
2182
+ // increments `version` (the ETag validator) from its prior value, preserving
2183
+ // any user-writable `custom` and the independent `metaVersion` already stored
2184
+ // in the sidecar (a content write does not touch the metadata sub-resource).
2185
+ //
2186
+ // `createdBy` pairs with `createdAt`: taken from this write's invoker only
2187
+ // when this write creates the sidecar, so it names the creator rather than
2188
+ // the last writer, and is preserved verbatim afterward -- including
2189
+ // preserved-as-absent, so a Resource created with no invoker never has a
2190
+ // later writer backfilled into it. A tombstone keeps both, so re-creating a
2191
+ // deleted id under a different invoker preserves the original creator, as
2192
+ // it does the original `createdAt`.
2193
+ const now = new Date().toISOString()
2194
+ const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2195
+ const version = (prior?.version ?? 0) + 1
2196
+ const creator = prior ? prior.createdBy : createdBy
2197
+ await this._writeMetaSidecar({
2198
+ collectionDir,
2199
+ resourceId,
2200
+ sidecar: {
2201
+ createdAt: prior?.createdAt ?? now,
2202
+ updatedAt: now,
2203
+ ...(creator !== undefined && { createdBy: creator }),
2204
+ version,
2205
+ ...(prior?.metaVersion !== undefined && {
2206
+ metaVersion: prior.metaVersion
2207
+ }),
2208
+ ...(prior?.custom && { custom: prior.custom }),
2209
+ // The key-epoch stamp is set from this write's declaration and CLEARED
2210
+ // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
2211
+ // worse than none), so it is NOT preserved from `prior` like `custom`.
2212
+ ...(epoch !== undefined && { epoch })
2213
+ }
2214
+ })
2215
+ return { version }
2216
+ }
2217
+
2218
+ /**
2219
+ * Writes a representation body (JSON value or byte stream) to `filePath`,
2220
+ * applying the same size guards every write path shares: the per-upload cap
2221
+ * (413 `PayloadTooLargeError`) and, when a byte quota is configured, the
2222
+ * Space headroom (507). A JSON body is fully in memory, so its size is checked
2223
+ * up front and written atomically; a binary body is streamed through the cap /
2224
+ * quota guards into a temp file and durably committed (fsync + rename + dir
2225
+ * fsync), removing the partial file on any failure. Shared by the Resource
2226
+ * write path (`_writeResourceLocked`) and the chunk write path
2227
+ * (`_writeChunkLocked`); the caller has already resolved `filePath` and ensured
2228
+ * its parent directory exists for the streamed case.
2229
+ * @param options {object}
2230
+ * @param options.spaceId {string}
2231
+ * @param options.filePath {string} absolute path of the representation file
2232
+ * @param options.input {ResourceInput}
2233
+ * @returns {Promise<void>}
2234
+ */
2235
+ private async _writeRepresentationBytes({
2236
+ spaceId,
2237
+ filePath,
2238
+ input
2239
+ }: {
2240
+ spaceId: string
2241
+ filePath: string
2242
+ input: ResourceInput
2243
+ }): Promise<void> {
2012
2244
  const { capacityBytes, maxUploadBytes } = this
2013
2245
 
2014
2246
  if (input.kind === 'json') {
@@ -2098,54 +2330,6 @@ export class FileSystemBackend implements StorageBackend {
2098
2330
  throw err
2099
2331
  }
2100
2332
  }
2101
-
2102
- // A Resource has a single current representation: remove any prior
2103
- // representation stored under a different content-type (its filename
2104
- // differs). Write-new-then-prune (not delete-then-write) so the resource is
2105
- // never momentarily absent.
2106
- const existing = await this._resourceFilesFor({ collectionDir, resourceId })
2107
- await Promise.all(
2108
- existing
2109
- .filter(name => path.resolve(name) !== path.resolve(filePath))
2110
- .map(name => rm(name))
2111
- )
2112
-
2113
- // Maintain the server-managed timestamps and the monotonic `version`: a
2114
- // content write sets `createdAt` on first write, bumps `updatedAt`, and
2115
- // increments `version` (the ETag validator) from its prior value, preserving
2116
- // any user-writable `custom` and the independent `metaVersion` already stored
2117
- // in the sidecar (a content write does not touch the metadata sub-resource).
2118
- //
2119
- // `createdBy` pairs with `createdAt`: taken from this write's invoker only
2120
- // when this write creates the sidecar, so it names the creator rather than
2121
- // the last writer, and is preserved verbatim afterward -- including
2122
- // preserved-as-absent, so a Resource created with no invoker never has a
2123
- // later writer backfilled into it. A tombstone keeps both, so re-creating a
2124
- // deleted id under a different invoker preserves the original creator, as
2125
- // it does the original `createdAt`.
2126
- const now = new Date().toISOString()
2127
- const prior = await this._readMetaSidecar({ collectionDir, resourceId })
2128
- const version = (prior?.version ?? 0) + 1
2129
- const creator = prior ? prior.createdBy : createdBy
2130
- await this._writeMetaSidecar({
2131
- collectionDir,
2132
- resourceId,
2133
- sidecar: {
2134
- createdAt: prior?.createdAt ?? now,
2135
- updatedAt: now,
2136
- ...(creator !== undefined && { createdBy: creator }),
2137
- version,
2138
- ...(prior?.metaVersion !== undefined && {
2139
- metaVersion: prior.metaVersion
2140
- }),
2141
- ...(prior?.custom && { custom: prior.custom }),
2142
- // The key-epoch stamp is set from this write's declaration and CLEARED
2143
- // when absent (the new ciphertext's epoch is unknown -- a stale stamp is
2144
- // worse than none), so it is NOT preserved from `prior` like `custom`.
2145
- ...(epoch !== undefined && { epoch })
2146
- }
2147
- })
2148
- return { version }
2149
2333
  }
2150
2334
 
2151
2335
  /**
@@ -2456,6 +2640,7 @@ export class FileSystemBackend implements StorageBackend {
2456
2640
  resourceId,
2457
2641
  custom,
2458
2642
  epoch,
2643
+ uniqueIndexes,
2459
2644
  ifMatch,
2460
2645
  ifNoneMatch
2461
2646
  }: {
@@ -2464,6 +2649,7 @@ export class FileSystemBackend implements StorageBackend {
2464
2649
  resourceId: string
2465
2650
  custom: ResourceMetadataCustom | Record<string, unknown>
2466
2651
  epoch?: string
2652
+ uniqueIndexes?: NormalizedIndexDeclaration[]
2467
2653
  ifMatch?: string
2468
2654
  ifNoneMatch?: boolean
2469
2655
  }): Promise<{ metaVersion: number } | undefined> {
@@ -2525,6 +2711,38 @@ export class FileSystemBackend implements StorageBackend {
2525
2711
  })
2526
2712
  return { metaVersion }
2527
2713
  }
2714
+ // A metadata write can create a plaintext equality unique claim for a
2715
+ // `custom`-sourced attribute (the `equality-query` feature). When the
2716
+ // Collection declares any unique index, serialize on the Collection lock and
2717
+ // scan for a conflict -- content is the Resource's stored JSON content
2718
+ // (unchanged by a metadata write), custom is the incoming value this write
2719
+ // sets -- before taking the per-Resource lock nested inside. Distinct-key
2720
+ // nesting cannot deadlock (plain writes never hold a Resource key while
2721
+ // waiting on a Collection key).
2722
+ if (uniqueIndexes !== undefined && uniqueIndexes.length > 0) {
2723
+ return this._writeMutex.run(
2724
+ this._collectionLockKey({ spaceId, collectionId }),
2725
+ async () => {
2726
+ assertNoUniqueEqualityConflict({
2727
+ indexes: uniqueIndexes,
2728
+ content: await this._readResourceJsonContent({
2729
+ collectionDir,
2730
+ resourceId
2731
+ }),
2732
+ custom,
2733
+ candidates: await this._readEqualityCandidates({
2734
+ spaceId,
2735
+ collectionId,
2736
+ excludeResourceId: resourceId
2737
+ })
2738
+ })
2739
+ return this._writeMutex.run(
2740
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
2741
+ writeMeta
2742
+ )
2743
+ }
2744
+ )
2745
+ }
2528
2746
  return this._writeMutex.run(
2529
2747
  this._resourceLockKey({ spaceId, collectionId, resourceId }),
2530
2748
  writeMeta
@@ -2598,6 +2816,15 @@ export class FileSystemBackend implements StorageBackend {
2598
2816
  )
2599
2817
  // Drop the content representation(s) but KEEP the sidecar as the tombstone.
2600
2818
  await Promise.all(filesForResource.map(filename => rm(filename)))
2819
+ // Cascade-delete the Resource's chunks (the `chunked-streams` feature): a
2820
+ // chunk must never outlive its parent Resource, so its whole chunk
2821
+ // directory goes with the content. Runs under the same per-Resource lock a
2822
+ // `writeChunk` takes, so a chunk write racing this delete cannot re-create
2823
+ // an orphan directory. `force` makes an absent chunk directory a no-op.
2824
+ await rm(this._chunkDir({ collectionDir, resourceId }), {
2825
+ recursive: true,
2826
+ force: true
2827
+ })
2601
2828
  // Bump `version` / `updatedAt` so the tombstone sorts after the Resource's
2602
2829
  // prior state in the change feed, and continues the monotonic version (a
2603
2830
  // later re-create reads this sidecar and keeps counting up). `custom` is
@@ -2630,6 +2857,439 @@ export class FileSystemBackend implements StorageBackend {
2630
2857
  )
2631
2858
  }
2632
2859
 
2860
+ // Chunks (the `chunked-streams` feature)
2861
+
2862
+ /**
2863
+ * Builds the on-disk path for a Resource's chunk directory
2864
+ * (`.chunks.<encodedResourceId>/`) inside its Collection dir. A hidden
2865
+ * subdirectory (leading `.`, so it is invisible to the `r.`-prefixed Collection
2866
+ * listing and the live-Resource count) that holds the Resource's chunk
2867
+ * representations; inside it a chunk is stored exactly like a Resource keyed by
2868
+ * its index (`r.<index>.<encodedContentType>.<ext>` plus a `.meta.<index>.json`
2869
+ * version sidecar), so the Resource file / sidecar helpers are reused verbatim
2870
+ * with the chunk directory as the `collectionDir`.
2871
+ * @param options {object}
2872
+ * @param options.collectionDir {string}
2873
+ * @param options.resourceId {string}
2874
+ * @returns {string}
2875
+ */
2876
+ _chunkDir({
2877
+ collectionDir,
2878
+ resourceId
2879
+ }: {
2880
+ collectionDir: string
2881
+ resourceId: string
2882
+ }): string {
2883
+ const chunkDir = path.join(collectionDir, chunkDirName(resourceId))
2884
+ this._assertContained(chunkDir)
2885
+ return chunkDir
2886
+ }
2887
+
2888
+ /**
2889
+ * Writes one chunk of a chunked Resource, keyed by
2890
+ * `(spaceId, collectionId, resourceId, chunkIndex)`. The parent Resource MUST
2891
+ * already exist (else `ResourceNotFoundError`, 404), checked under the same
2892
+ * per-Resource lock a `deleteResource` cascade takes so a chunk can never be
2893
+ * orphaned by a racing delete. The body is stored opaquely (bytes +
2894
+ * content-type) through the shared upload-cap / quota guards, and the chunk's
2895
+ * own monotonic `version` (its ETag validator, independent of the parent's) is
2896
+ * bumped; any `If-Match` / `If-None-Match` precondition is evaluated on that
2897
+ * version atomically with the write (`PreconditionFailedError`, 412).
2898
+ * @param options {object}
2899
+ * @param options.spaceId {string}
2900
+ * @param options.collectionId {string}
2901
+ * @param options.resourceId {string}
2902
+ * @param options.chunkIndex {number} non-negative integer chunk position
2903
+ * @param options.input {ResourceInput}
2904
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
2905
+ * @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
2906
+ * @returns {Promise<{ version: number }>} the chunk's new version
2907
+ */
2908
+ async writeChunk({
2909
+ spaceId,
2910
+ collectionId,
2911
+ resourceId,
2912
+ chunkIndex,
2913
+ input,
2914
+ ifMatch,
2915
+ ifNoneMatch
2916
+ }: {
2917
+ spaceId: string
2918
+ collectionId: string
2919
+ resourceId: string
2920
+ chunkIndex: number
2921
+ input: ResourceInput
2922
+ ifMatch?: string
2923
+ ifNoneMatch?: boolean
2924
+ }): Promise<{ version: number }> {
2925
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
2926
+ // Serialize on the parent Resource's lock key -- the same key
2927
+ // `deleteResource` takes -- so the parent-exists check, the write, and the
2928
+ // cascade delete cannot interleave (no orphan chunk).
2929
+ return this._writeMutex.run(
2930
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
2931
+ () =>
2932
+ this._writeChunkLocked({
2933
+ spaceId,
2934
+ collectionDir,
2935
+ resourceId,
2936
+ chunkIndex,
2937
+ input,
2938
+ ifMatch,
2939
+ ifNoneMatch
2940
+ })
2941
+ )
2942
+ }
2943
+
2944
+ /**
2945
+ * The critical section of `writeChunk`, run under the per-Resource lock. See
2946
+ * `writeChunk` for the parameters.
2947
+ * @returns {Promise<{ version: number }>}
2948
+ */
2949
+ private async _writeChunkLocked({
2950
+ spaceId,
2951
+ collectionDir,
2952
+ resourceId,
2953
+ chunkIndex,
2954
+ input,
2955
+ ifMatch,
2956
+ ifNoneMatch
2957
+ }: {
2958
+ spaceId: string
2959
+ collectionDir: string
2960
+ resourceId: string
2961
+ chunkIndex: number
2962
+ input: ResourceInput
2963
+ ifMatch?: string
2964
+ ifNoneMatch?: boolean
2965
+ }): Promise<{ version: number }> {
2966
+ // The parent Resource must exist: writing a chunk of an absent Resource
2967
+ // rejects, so orphan chunks cannot accumulate.
2968
+ const parentExists =
2969
+ (await this._findFile({ collectionDir, resourceId })) !== undefined
2970
+ if (!parentExists) {
2971
+ throw new ResourceNotFoundError({ requestName: 'Write Chunk' })
2972
+ }
2973
+
2974
+ // Inside the chunk directory a chunk is a Resource keyed by its index, so the
2975
+ // Resource file / sidecar helpers apply with `chunkDir` as the collectionDir
2976
+ // and the stringified index as the resourceId.
2977
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
2978
+ const chunkId = String(chunkIndex)
2979
+ const filename = fileNameFor({
2980
+ resourceId: chunkId,
2981
+ contentType: input.contentType
2982
+ })
2983
+ const filePath = path.join(chunkDir, filename)
2984
+ this._assertContained(filePath)
2985
+
2986
+ // Evaluate any precondition against the chunk's current version before
2987
+ // writing (still inside the lock, so check and write are atomic).
2988
+ if (ifMatch !== undefined || ifNoneMatch) {
2989
+ await this._assertWritePrecondition({
2990
+ collectionDir: chunkDir,
2991
+ resourceId: chunkId,
2992
+ ifMatch,
2993
+ ifNoneMatch
2994
+ })
2995
+ }
2996
+
2997
+ // The chunk directory is created lazily on first write (a binary body is
2998
+ // streamed into a temp file in it, so it must exist first).
2999
+ await mkdir(chunkDir, { recursive: true })
3000
+ await this._writeRepresentationBytes({ spaceId, filePath, input })
3001
+
3002
+ // A chunk has a single current representation: remove any prior one stored
3003
+ // under a different content-type (write-new-then-prune).
3004
+ const existing = await this._resourceFilesFor({
3005
+ collectionDir: chunkDir,
3006
+ resourceId: chunkId
3007
+ })
3008
+ await Promise.all(
3009
+ existing
3010
+ .filter(name => path.resolve(name) !== path.resolve(filePath))
3011
+ .map(name => rm(name))
3012
+ )
3013
+
3014
+ // Bump the chunk's monotonic `version` (its ETag validator), preserving its
3015
+ // `createdAt`. A chunk carries no user Metadata / `createdBy` / epoch stamp.
3016
+ const now = new Date().toISOString()
3017
+ const prior = await this._readMetaSidecar({
3018
+ collectionDir: chunkDir,
3019
+ resourceId: chunkId
3020
+ })
3021
+ const version = (prior?.version ?? 0) + 1
3022
+ await this._writeMetaSidecar({
3023
+ collectionDir: chunkDir,
3024
+ resourceId: chunkId,
3025
+ sidecar: {
3026
+ createdAt: prior?.createdAt ?? now,
3027
+ updatedAt: now,
3028
+ version
3029
+ }
3030
+ })
3031
+ return { version }
3032
+ }
3033
+
3034
+ /**
3035
+ * Reads a chunk's bytes, resolving a `ResourceResult` (stream + resolved
3036
+ * content-type + the chunk's `version`). Throws `ResourceNotFoundError` (404)
3037
+ * when the chunk is absent.
3038
+ * @param options {object}
3039
+ * @param options.spaceId {string}
3040
+ * @param options.collectionId {string}
3041
+ * @param options.resourceId {string}
3042
+ * @param options.chunkIndex {number}
3043
+ * @returns {Promise<ResourceResult>}
3044
+ */
3045
+ async getChunk({
3046
+ spaceId,
3047
+ collectionId,
3048
+ resourceId,
3049
+ chunkIndex
3050
+ }: {
3051
+ spaceId: string
3052
+ collectionId: string
3053
+ resourceId: string
3054
+ chunkIndex: number
3055
+ }): Promise<ResourceResult> {
3056
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3057
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3058
+ const chunkId = String(chunkIndex)
3059
+ const filePath = await this._findFile({
3060
+ collectionDir: chunkDir,
3061
+ resourceId: chunkId
3062
+ })
3063
+ if (!filePath) {
3064
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
3065
+ }
3066
+
3067
+ try {
3068
+ await fsStat(filePath)
3069
+ } catch (err) {
3070
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3071
+ throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
3072
+ }
3073
+ throw err
3074
+ }
3075
+
3076
+ const { contentType: storedResourceType } = parseResourceFileName(
3077
+ path.basename(filePath)
3078
+ )
3079
+ const sidecar = await this._readMetaSidecar({
3080
+ collectionDir: chunkDir,
3081
+ resourceId: chunkId
3082
+ })
3083
+ return {
3084
+ resourceStream: await openFileStream(filePath, this.logger),
3085
+ storedResourceType,
3086
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3087
+ }
3088
+ }
3089
+
3090
+ /**
3091
+ * Reads a chunk's stored content-type / size / version (the HEAD payload
3092
+ * headers). Resolves `undefined` when the chunk is absent.
3093
+ * @param options {object}
3094
+ * @param options.spaceId {string}
3095
+ * @param options.collectionId {string}
3096
+ * @param options.resourceId {string}
3097
+ * @param options.chunkIndex {number}
3098
+ * @returns {Promise<{ contentType: string, size: number, version?: number } |
3099
+ * undefined>}
3100
+ */
3101
+ async getChunkMetadata({
3102
+ spaceId,
3103
+ collectionId,
3104
+ resourceId,
3105
+ chunkIndex
3106
+ }: {
3107
+ spaceId: string
3108
+ collectionId: string
3109
+ resourceId: string
3110
+ chunkIndex: number
3111
+ }): Promise<
3112
+ { contentType: string; size: number; version?: number } | undefined
3113
+ > {
3114
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3115
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3116
+ const chunkId = String(chunkIndex)
3117
+ const filePath = await this._findFile({
3118
+ collectionDir: chunkDir,
3119
+ resourceId: chunkId
3120
+ })
3121
+ if (!filePath) {
3122
+ return undefined
3123
+ }
3124
+
3125
+ let stats
3126
+ try {
3127
+ stats = await fsStat(filePath)
3128
+ } catch (err) {
3129
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3130
+ return undefined
3131
+ }
3132
+ throw err
3133
+ }
3134
+
3135
+ const { contentType } = parseResourceFileName(path.basename(filePath))
3136
+ const sidecar = await this._readMetaSidecar({
3137
+ collectionDir: chunkDir,
3138
+ resourceId: chunkId
3139
+ })
3140
+ return {
3141
+ contentType,
3142
+ size: stats.size,
3143
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3144
+ }
3145
+ }
3146
+
3147
+ /**
3148
+ * Deletes one chunk (a hard delete: its bytes and version sidecar both go, and
3149
+ * -- unlike a Resource -- it leaves no tombstone, since chunks are not part of
3150
+ * the change feed). Resolves `true` when a chunk was removed and `false` when
3151
+ * none was stored at that index. When `ifMatch` is supplied it is evaluated on
3152
+ * the chunk's current version atomically with the removal (under the same
3153
+ * per-Resource lock), throwing `PreconditionFailedError` (412) on a mismatch.
3154
+ * @param options {object}
3155
+ * @param options.spaceId {string}
3156
+ * @param options.collectionId {string}
3157
+ * @param options.resourceId {string}
3158
+ * @param options.chunkIndex {number}
3159
+ * @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
3160
+ * @returns {Promise<boolean>}
3161
+ */
3162
+ async deleteChunk({
3163
+ spaceId,
3164
+ collectionId,
3165
+ resourceId,
3166
+ chunkIndex,
3167
+ ifMatch
3168
+ }: {
3169
+ spaceId: string
3170
+ collectionId: string
3171
+ resourceId: string
3172
+ chunkIndex: number
3173
+ ifMatch?: string
3174
+ }): Promise<boolean> {
3175
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3176
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3177
+ const chunkId = String(chunkIndex)
3178
+ return this._writeMutex.run(
3179
+ this._resourceLockKey({ spaceId, collectionId, resourceId }),
3180
+ async () => {
3181
+ const files = await this._resourceFilesFor({
3182
+ collectionDir: chunkDir,
3183
+ resourceId: chunkId
3184
+ })
3185
+ if (files.length === 0) {
3186
+ // Absent: the handler 404s on `false` (chunk deletes are not silently
3187
+ // idempotent, mirroring the EDV chunk contract).
3188
+ return false
3189
+ }
3190
+ if (ifMatch !== undefined) {
3191
+ await this._assertWritePrecondition({
3192
+ collectionDir: chunkDir,
3193
+ resourceId: chunkId,
3194
+ ifMatch
3195
+ })
3196
+ }
3197
+ await Promise.all(files.map(name => rm(name)))
3198
+ // Remove the version sidecar too: a chunk keeps no tombstone.
3199
+ await rm(
3200
+ this._metaSidecarPath({
3201
+ collectionDir: chunkDir,
3202
+ resourceId: chunkId
3203
+ }),
3204
+ { force: true }
3205
+ )
3206
+ // When that was the last chunk, remove the now-empty chunk directory
3207
+ // itself: a lingering empty `.chunks.<encId>/` would otherwise appear
3208
+ // in the export walk (diverging from a Postgres export of the same
3209
+ // logical state) and count its allocated block toward the du-based
3210
+ // quota measurement. Safe under the per-Resource lock (`writeChunk`
3211
+ // serializes on the same key, so nothing lands in the directory
3212
+ // between the check and the rmdir).
3213
+ const remaining = await fs.promises.readdir(chunkDir)
3214
+ if (remaining.length === 0) {
3215
+ await fs.promises.rmdir(chunkDir)
3216
+ }
3217
+ // Freed bytes: drop the cached quota usage so the next write re-measures.
3218
+ this._usageCache.delete(spaceId)
3219
+ return true
3220
+ }
3221
+ )
3222
+ }
3223
+
3224
+ /**
3225
+ * Lists a Resource's stored chunks in ascending `index` order -- the
3226
+ * discovery/reassembly listing (the server never reassembles). Resolves an
3227
+ * empty listing when the Resource has no chunk directory (including when the
3228
+ * Resource itself is absent -- existence is the parent routes' concern).
3229
+ * @param options {object}
3230
+ * @param options.spaceId {string}
3231
+ * @param options.collectionId {string}
3232
+ * @param options.resourceId {string}
3233
+ * @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
3234
+ * number, contentType: string, version?: number }> }>}
3235
+ */
3236
+ async listChunks({
3237
+ spaceId,
3238
+ collectionId,
3239
+ resourceId
3240
+ }: {
3241
+ spaceId: string
3242
+ collectionId: string
3243
+ resourceId: string
3244
+ }): Promise<{
3245
+ count: number
3246
+ chunks: Array<{
3247
+ index: number
3248
+ size: number
3249
+ contentType: string
3250
+ version?: number
3251
+ }>
3252
+ }> {
3253
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3254
+ const chunkDir = this._chunkDir({ collectionDir, resourceId })
3255
+ let entries: fs.Dirent[]
3256
+ try {
3257
+ entries = await fs.promises.readdir(chunkDir, { withFileTypes: true })
3258
+ } catch (err) {
3259
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
3260
+ return { count: 0, chunks: [] }
3261
+ }
3262
+ throw err
3263
+ }
3264
+
3265
+ // Keep only chunk representations (`r.<index>.<type>.<ext>`), dropping the
3266
+ // `.meta.<index>.json` version sidecars.
3267
+ const chunkEntries = entries.filter(
3268
+ entry => entry.isFile() && entry.name.startsWith('r.')
3269
+ )
3270
+ const chunks = await Promise.all(
3271
+ chunkEntries.map(async entry => {
3272
+ const { resourceId: indexStr, contentType } = parseResourceFileName(
3273
+ entry.name
3274
+ )
3275
+ const filePath = path.join(chunkDir, entry.name)
3276
+ const stats = await fsStat(filePath)
3277
+ const sidecar = await this._readMetaSidecar({
3278
+ collectionDir: chunkDir,
3279
+ resourceId: indexStr
3280
+ })
3281
+ return {
3282
+ index: Number(indexStr),
3283
+ size: stats.size,
3284
+ contentType,
3285
+ ...(sidecar?.version !== undefined && { version: sidecar.version })
3286
+ }
3287
+ })
3288
+ )
3289
+ chunks.sort((left, right) => left.index - right.index)
3290
+ return { count: chunks.length, chunks }
3291
+ }
3292
+
2633
3293
  /**
2634
3294
  * Replication change feed (the `changes` query profile; see the
2635
3295
  * `StorageBackend.changesSince` contract.
@@ -2996,6 +3656,195 @@ export class FileSystemBackend implements StorageBackend {
2996
3656
  ).filter(candidate => candidate !== undefined)
2997
3657
  }
2998
3658
 
3659
+ /**
3660
+ * Plaintext equality query (the `equality` query profile; see the
3661
+ * `StorageBackend.queryByEquality` contract). Reads every live Resource of
3662
+ * the Collection -- JSON Resources carrying parsed `content`, blobs carrying
3663
+ * only their sidecar `custom` -- and hands the candidates to the shared
3664
+ * evaluator (`lib/equalityIndex.ts`) for extraction, matching, ordering, and
3665
+ * cursor pagination. O(n) over the Collection per call, with every JSON body
3666
+ * read -- deliberate for this teaching backend; a materialized backend would
3667
+ * answer from an attribute index. Tombstones are excluded naturally (no live
3668
+ * content file).
3669
+ * @param options {object}
3670
+ * @param options.spaceId {string}
3671
+ * @param options.collectionId {string}
3672
+ * @param options.query {EqualityQuery}
3673
+ * @param options.indexes {NormalizedIndexDeclaration[]} the normalized
3674
+ * declared indexes (the request layer resolves them from the description)
3675
+ * @param [options.count] {boolean} return only the match count
3676
+ * @param [options.limit] {number} requested page size
3677
+ * @param [options.cursor] {string} opaque cursor from a prior page
3678
+ * @returns {Promise<{ count: number } | EqualityQueryPage>}
3679
+ */
3680
+ async queryByEquality({
3681
+ spaceId,
3682
+ collectionId,
3683
+ query,
3684
+ indexes,
3685
+ count,
3686
+ limit,
3687
+ cursor
3688
+ }: {
3689
+ spaceId: string
3690
+ collectionId: string
3691
+ query: EqualityQuery
3692
+ indexes: NormalizedIndexDeclaration[]
3693
+ count?: boolean
3694
+ limit?: number
3695
+ cursor?: string
3696
+ }): Promise<{ count: number } | EqualityQueryPage> {
3697
+ const candidates = await this._readEqualityCandidates({
3698
+ spaceId,
3699
+ collectionId
3700
+ })
3701
+ return runEqualityQuery({
3702
+ candidates,
3703
+ query,
3704
+ indexes,
3705
+ count,
3706
+ limit,
3707
+ cursor
3708
+ })
3709
+ }
3710
+
3711
+ /**
3712
+ * Declare-time uniqueness scan for the `equality` profile (see the
3713
+ * `StorageBackend.findEqualityUniqueViolation` contract): reads the
3714
+ * Collection's live Resources and delegates to the shared scan, which reports
3715
+ * the first `(name, value)` claimed by two different Resources under the given
3716
+ * `unique` declarations (or `undefined` when none is).
3717
+ * @param options {object}
3718
+ * @param options.spaceId {string}
3719
+ * @param options.collectionId {string}
3720
+ * @param options.indexes {NormalizedIndexDeclaration[]}
3721
+ * @returns {Promise<{ name: string, value: EqualityValue } | undefined>}
3722
+ */
3723
+ async findEqualityUniqueViolation({
3724
+ spaceId,
3725
+ collectionId,
3726
+ indexes
3727
+ }: {
3728
+ spaceId: string
3729
+ collectionId: string
3730
+ indexes: NormalizedIndexDeclaration[]
3731
+ }): Promise<{ name: string; value: EqualityValue } | undefined> {
3732
+ const candidates = await this._readEqualityCandidates({
3733
+ spaceId,
3734
+ collectionId
3735
+ })
3736
+ return findEqualityUniqueViolation({ indexes, candidates })
3737
+ }
3738
+
3739
+ /**
3740
+ * Reads every live Resource of a Collection as an equality candidate -- the
3741
+ * candidate set for the equality query and the plaintext unique-attribute
3742
+ * conflict scans. Unlike `_readJsonCandidates` this INCLUDES blob Resources
3743
+ * (a blob is queryable through its `custom`-sourced attributes): each entry
3744
+ * resolves `{ resourceId, content?, custom? }`, where `content` is the parsed
3745
+ * JSON of a JSON-typed representation (the blob content read is skipped, and
3746
+ * unparsable JSON is dropped, as in `_readJsonCandidates`) and `custom` is the
3747
+ * `.meta.` sidecar's `custom` when present. Tombstones are excluded naturally
3748
+ * (no live `r.` content file); an optional excluded Resource is skipped. An
3749
+ * absent Collection dir resolves empty.
3750
+ * @param options {object}
3751
+ * @param options.spaceId {string}
3752
+ * @param options.collectionId {string}
3753
+ * @param [options.excludeResourceId] {string} omit this Resource (a conflict
3754
+ * scan excludes the Resource being written)
3755
+ * @returns {Promise<EqualityCandidate[]>}
3756
+ */
3757
+ private async _readEqualityCandidates({
3758
+ spaceId,
3759
+ collectionId,
3760
+ excludeResourceId
3761
+ }: {
3762
+ spaceId: string
3763
+ collectionId: string
3764
+ excludeResourceId?: string
3765
+ }): Promise<EqualityCandidate[]> {
3766
+ const collectionDir = this._collectionDir({ spaceId, collectionId })
3767
+
3768
+ let entries: fs.Dirent[] = []
3769
+ try {
3770
+ entries = await fs.promises.readdir(collectionDir, {
3771
+ withFileTypes: true
3772
+ })
3773
+ } catch (err) {
3774
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
3775
+ throw err
3776
+ }
3777
+ }
3778
+
3779
+ return await Promise.all(
3780
+ entries
3781
+ .filter(entry => entry.isFile() && entry.name.startsWith('r.'))
3782
+ .map(entry => ({
3783
+ fileName: entry.name,
3784
+ ...parseResourceFileName(entry.name)
3785
+ }))
3786
+ .filter(({ resourceId }) => resourceId !== excludeResourceId)
3787
+ .map(async ({ resourceId, fileName, contentType }) => {
3788
+ // Parse the content only for a JSON representation; a blob contributes
3789
+ // no content-sourced attributes (its `custom` still makes it
3790
+ // queryable). Unparsable JSON is treated as no content.
3791
+ let content: unknown
3792
+ if (isJson({ contentType })) {
3793
+ try {
3794
+ content = JSON.parse(
3795
+ await fs.promises.readFile(
3796
+ path.join(collectionDir, fileName),
3797
+ 'utf8'
3798
+ )
3799
+ ) as unknown
3800
+ } catch {
3801
+ content = undefined
3802
+ }
3803
+ }
3804
+ const sidecar = await this._readMetaSidecar({
3805
+ collectionDir,
3806
+ resourceId
3807
+ })
3808
+ return {
3809
+ resourceId,
3810
+ ...(content !== undefined && { content }),
3811
+ ...(sidecar?.custom !== undefined && { custom: sidecar.custom })
3812
+ }
3813
+ })
3814
+ )
3815
+ }
3816
+
3817
+ /**
3818
+ * Reads and parses a single Resource's stored JSON content, or resolves
3819
+ * `undefined` when the Resource is absent, a blob, or unparsable JSON -- the
3820
+ * content side of a custom-sourced unique-attribute claim on a metadata write.
3821
+ * @param options {object}
3822
+ * @param options.collectionDir {string}
3823
+ * @param options.resourceId {string}
3824
+ * @returns {Promise<unknown>}
3825
+ */
3826
+ private async _readResourceJsonContent({
3827
+ collectionDir,
3828
+ resourceId
3829
+ }: {
3830
+ collectionDir: string
3831
+ resourceId: string
3832
+ }): Promise<unknown> {
3833
+ const filePath = await this._findFile({ collectionDir, resourceId })
3834
+ if (!filePath) {
3835
+ return undefined
3836
+ }
3837
+ const { contentType } = parseResourceFileName(path.basename(filePath))
3838
+ if (!isJson({ contentType })) {
3839
+ return undefined
3840
+ }
3841
+ try {
3842
+ return JSON.parse(await fs.promises.readFile(filePath, 'utf8')) as unknown
3843
+ } catch {
3844
+ return undefined
3845
+ }
3846
+ }
3847
+
2999
3848
  // Policies
3000
3849
 
3001
3850
  /**