was-teaching-server 0.9.1

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 (295) hide show
  1. package/CHANGELOG.md +1432 -0
  2. package/LICENSE +661 -0
  3. package/README.md +305 -0
  4. package/common/css/materialize.1.0.0.min.css +13 -0
  5. package/dist/auth-header-hooks.d.ts +84 -0
  6. package/dist/auth-header-hooks.d.ts.map +1 -0
  7. package/dist/auth-header-hooks.js +136 -0
  8. package/dist/auth-header-hooks.js.map +1 -0
  9. package/dist/authorize.d.ts +45 -0
  10. package/dist/authorize.d.ts.map +1 -0
  11. package/dist/authorize.js +76 -0
  12. package/dist/authorize.js.map +1 -0
  13. package/dist/backends/filesystem.d.ts +1179 -0
  14. package/dist/backends/filesystem.d.ts.map +1 -0
  15. package/dist/backends/filesystem.js +2727 -0
  16. package/dist/backends/filesystem.js.map +1 -0
  17. package/dist/backends/postgres.d.ts +833 -0
  18. package/dist/backends/postgres.d.ts.map +1 -0
  19. package/dist/backends/postgres.js +2164 -0
  20. package/dist/backends/postgres.js.map +1 -0
  21. package/dist/backends/postgresSchema.d.ts +36 -0
  22. package/dist/backends/postgresSchema.d.ts.map +1 -0
  23. package/dist/backends/postgresSchema.js +203 -0
  24. package/dist/backends/postgresSchema.js.map +1 -0
  25. package/dist/config.default.d.ts +360 -0
  26. package/dist/config.default.d.ts.map +1 -0
  27. package/dist/config.default.js +493 -0
  28. package/dist/config.default.js.map +1 -0
  29. package/dist/corsProxy.d.ts +45 -0
  30. package/dist/corsProxy.d.ts.map +1 -0
  31. package/dist/corsProxy.js +278 -0
  32. package/dist/corsProxy.js.map +1 -0
  33. package/dist/digest.d.ts +45 -0
  34. package/dist/digest.d.ts.map +1 -0
  35. package/dist/digest.js +174 -0
  36. package/dist/digest.js.map +1 -0
  37. package/dist/errors.d.ts +654 -0
  38. package/dist/errors.d.ts.map +1 -0
  39. package/dist/errors.js +960 -0
  40. package/dist/errors.js.map +1 -0
  41. package/dist/index.d.ts +22 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +21 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/lib/atomicFile.d.ts +61 -0
  46. package/dist/lib/atomicFile.d.ts.map +1 -0
  47. package/dist/lib/atomicFile.js +150 -0
  48. package/dist/lib/atomicFile.js.map +1 -0
  49. package/dist/lib/backendRegistry.d.ts +59 -0
  50. package/dist/lib/backendRegistry.d.ts.map +1 -0
  51. package/dist/lib/backendRegistry.js +92 -0
  52. package/dist/lib/backendRegistry.js.map +1 -0
  53. package/dist/lib/backendUsage.d.ts +28 -0
  54. package/dist/lib/backendUsage.d.ts.map +1 -0
  55. package/dist/lib/backendUsage.js +55 -0
  56. package/dist/lib/backendUsage.js.map +1 -0
  57. package/dist/lib/backends.d.ts +142 -0
  58. package/dist/lib/backends.d.ts.map +1 -0
  59. package/dist/lib/backends.js +272 -0
  60. package/dist/lib/backends.js.map +1 -0
  61. package/dist/lib/blindedIndex.d.ts +156 -0
  62. package/dist/lib/blindedIndex.d.ts.map +1 -0
  63. package/dist/lib/blindedIndex.js +338 -0
  64. package/dist/lib/blindedIndex.js.map +1 -0
  65. package/dist/lib/collectionDescription.d.ts +33 -0
  66. package/dist/lib/collectionDescription.d.ts.map +1 -0
  67. package/dist/lib/collectionDescription.js +21 -0
  68. package/dist/lib/collectionDescription.js.map +1 -0
  69. package/dist/lib/cursor.d.ts +18 -0
  70. package/dist/lib/cursor.d.ts.map +1 -0
  71. package/dist/lib/cursor.js +50 -0
  72. package/dist/lib/cursor.js.map +1 -0
  73. package/dist/lib/edvEnvelope.d.ts +50 -0
  74. package/dist/lib/edvEnvelope.d.ts.map +1 -0
  75. package/dist/lib/edvEnvelope.js +106 -0
  76. package/dist/lib/edvEnvelope.js.map +1 -0
  77. package/dist/lib/encryption.d.ts +192 -0
  78. package/dist/lib/encryption.d.ts.map +1 -0
  79. package/dist/lib/encryption.js +383 -0
  80. package/dist/lib/encryption.js.map +1 -0
  81. package/dist/lib/etag.d.ts +33 -0
  82. package/dist/lib/etag.d.ts.map +1 -0
  83. package/dist/lib/etag.js +36 -0
  84. package/dist/lib/etag.js.map +1 -0
  85. package/dist/lib/exportManifest.d.ts +29 -0
  86. package/dist/lib/exportManifest.d.ts.map +1 -0
  87. package/dist/lib/exportManifest.js +77 -0
  88. package/dist/lib/exportManifest.js.map +1 -0
  89. package/dist/lib/importTar.d.ts +81 -0
  90. package/dist/lib/importTar.d.ts.map +1 -0
  91. package/dist/lib/importTar.js +301 -0
  92. package/dist/lib/importTar.js.map +1 -0
  93. package/dist/lib/isJson.d.ts +16 -0
  94. package/dist/lib/isJson.d.ts.map +1 -0
  95. package/dist/lib/isJson.js +17 -0
  96. package/dist/lib/isJson.js.map +1 -0
  97. package/dist/lib/keyEpoch.d.ts +38 -0
  98. package/dist/lib/keyEpoch.d.ts.map +1 -0
  99. package/dist/lib/keyEpoch.js +75 -0
  100. package/dist/lib/keyEpoch.js.map +1 -0
  101. package/dist/lib/keyedMutex.d.ts +28 -0
  102. package/dist/lib/keyedMutex.d.ts.map +1 -0
  103. package/dist/lib/keyedMutex.js +45 -0
  104. package/dist/lib/keyedMutex.js.map +1 -0
  105. package/dist/lib/kmsModule.d.ts +62 -0
  106. package/dist/lib/kmsModule.d.ts.map +1 -0
  107. package/dist/lib/kmsModule.js +327 -0
  108. package/dist/lib/kmsModule.js.map +1 -0
  109. package/dist/lib/kmsRecordCipher.d.ts +82 -0
  110. package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
  111. package/dist/lib/kmsRecordCipher.js +226 -0
  112. package/dist/lib/kmsRecordCipher.js.map +1 -0
  113. package/dist/lib/pagination.d.ts +19 -0
  114. package/dist/lib/pagination.d.ts.map +1 -0
  115. package/dist/lib/pagination.js +21 -0
  116. package/dist/lib/pagination.js.map +1 -0
  117. package/dist/lib/paths.d.ts +273 -0
  118. package/dist/lib/paths.d.ts.map +1 -0
  119. package/dist/lib/paths.js +270 -0
  120. package/dist/lib/paths.js.map +1 -0
  121. package/dist/lib/preconditions.d.ts +65 -0
  122. package/dist/lib/preconditions.d.ts.map +1 -0
  123. package/dist/lib/preconditions.js +112 -0
  124. package/dist/lib/preconditions.js.map +1 -0
  125. package/dist/lib/resourceFileName.d.ts +41 -0
  126. package/dist/lib/resourceFileName.d.ts.map +1 -0
  127. package/dist/lib/resourceFileName.js +58 -0
  128. package/dist/lib/resourceFileName.js.map +1 -0
  129. package/dist/lib/revocations.d.ts +49 -0
  130. package/dist/lib/revocations.d.ts.map +1 -0
  131. package/dist/lib/revocations.js +86 -0
  132. package/dist/lib/revocations.js.map +1 -0
  133. package/dist/lib/validateDid.d.ts +19 -0
  134. package/dist/lib/validateDid.d.ts.map +1 -0
  135. package/dist/lib/validateDid.js +35 -0
  136. package/dist/lib/validateDid.js.map +1 -0
  137. package/dist/lib/validateId.d.ts +66 -0
  138. package/dist/lib/validateId.d.ts.map +1 -0
  139. package/dist/lib/validateId.js +126 -0
  140. package/dist/lib/validateId.js.map +1 -0
  141. package/dist/plugin.d.ts +115 -0
  142. package/dist/plugin.d.ts.map +1 -0
  143. package/dist/plugin.js +134 -0
  144. package/dist/plugin.js.map +1 -0
  145. package/dist/policy.d.ts +78 -0
  146. package/dist/policy.d.ts.map +1 -0
  147. package/dist/policy.js +115 -0
  148. package/dist/policy.js.map +1 -0
  149. package/dist/provisioning.d.ts +38 -0
  150. package/dist/provisioning.d.ts.map +1 -0
  151. package/dist/provisioning.js +72 -0
  152. package/dist/provisioning.js.map +1 -0
  153. package/dist/requests/BackendRequest.d.ts +61 -0
  154. package/dist/requests/BackendRequest.d.ts.map +1 -0
  155. package/dist/requests/BackendRequest.js +152 -0
  156. package/dist/requests/BackendRequest.js.map +1 -0
  157. package/dist/requests/CollectionRequest.d.ts +234 -0
  158. package/dist/requests/CollectionRequest.d.ts.map +1 -0
  159. package/dist/requests/CollectionRequest.js +705 -0
  160. package/dist/requests/CollectionRequest.js.map +1 -0
  161. package/dist/requests/KeyRequest.d.ts +115 -0
  162. package/dist/requests/KeyRequest.d.ts.map +1 -0
  163. package/dist/requests/KeyRequest.js +468 -0
  164. package/dist/requests/KeyRequest.js.map +1 -0
  165. package/dist/requests/KeystoreRequest.d.ts +99 -0
  166. package/dist/requests/KeystoreRequest.d.ts.map +1 -0
  167. package/dist/requests/KeystoreRequest.js +247 -0
  168. package/dist/requests/KeystoreRequest.js.map +1 -0
  169. package/dist/requests/PolicyRequest.d.ts +53 -0
  170. package/dist/requests/PolicyRequest.d.ts.map +1 -0
  171. package/dist/requests/PolicyRequest.js +124 -0
  172. package/dist/requests/PolicyRequest.js.map +1 -0
  173. package/dist/requests/ResourceRequest.d.ts +134 -0
  174. package/dist/requests/ResourceRequest.d.ts.map +1 -0
  175. package/dist/requests/ResourceRequest.js +548 -0
  176. package/dist/requests/ResourceRequest.js.map +1 -0
  177. package/dist/requests/RevocationRequest.d.ts +67 -0
  178. package/dist/requests/RevocationRequest.d.ts.map +1 -0
  179. package/dist/requests/RevocationRequest.js +187 -0
  180. package/dist/requests/RevocationRequest.js.map +1 -0
  181. package/dist/requests/SpaceRequest.d.ts +204 -0
  182. package/dist/requests/SpaceRequest.d.ts.map +1 -0
  183. package/dist/requests/SpaceRequest.js +520 -0
  184. package/dist/requests/SpaceRequest.js.map +1 -0
  185. package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
  186. package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
  187. package/dist/requests/SpacesRepositoryRequest.js +159 -0
  188. package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
  189. package/dist/requests/collectionContext.d.ts +21 -0
  190. package/dist/requests/collectionContext.d.ts.map +1 -0
  191. package/dist/requests/collectionContext.js +30 -0
  192. package/dist/requests/collectionContext.js.map +1 -0
  193. package/dist/requests/controllerConsent.d.ts +58 -0
  194. package/dist/requests/controllerConsent.d.ts.map +1 -0
  195. package/dist/requests/controllerConsent.js +73 -0
  196. package/dist/requests/controllerConsent.js.map +1 -0
  197. package/dist/requests/keystoreContext.d.ts +68 -0
  198. package/dist/requests/keystoreContext.d.ts.map +1 -0
  199. package/dist/requests/keystoreContext.js +87 -0
  200. package/dist/requests/keystoreContext.js.map +1 -0
  201. package/dist/requests/resourceInput.d.ts +19 -0
  202. package/dist/requests/resourceInput.d.ts.map +1 -0
  203. package/dist/requests/resourceInput.js +102 -0
  204. package/dist/requests/resourceInput.js.map +1 -0
  205. package/dist/requests/spaceContext.d.ts +127 -0
  206. package/dist/requests/spaceContext.d.ts.map +1 -0
  207. package/dist/requests/spaceContext.js +201 -0
  208. package/dist/requests/spaceContext.js.map +1 -0
  209. package/dist/routes.d.ts +55 -0
  210. package/dist/routes.d.ts.map +1 -0
  211. package/dist/routes.js +306 -0
  212. package/dist/routes.js.map +1 -0
  213. package/dist/server.d.ts +20 -0
  214. package/dist/server.d.ts.map +1 -0
  215. package/dist/server.js +60 -0
  216. package/dist/server.js.map +1 -0
  217. package/dist/start.d.ts +7 -0
  218. package/dist/start.d.ts.map +1 -0
  219. package/dist/start.js +61 -0
  220. package/dist/start.js.map +1 -0
  221. package/dist/storage.d.ts +29 -0
  222. package/dist/storage.d.ts.map +1 -0
  223. package/dist/storage.js +47 -0
  224. package/dist/storage.js.map +1 -0
  225. package/dist/types.d.ts +915 -0
  226. package/dist/types.d.ts.map +1 -0
  227. package/dist/types.js +2 -0
  228. package/dist/types.js.map +1 -0
  229. package/dist/views/home.hbs +9 -0
  230. package/dist/views/templates/main.hbs +12 -0
  231. package/dist/zcap.d.ts +267 -0
  232. package/dist/zcap.d.ts.map +1 -0
  233. package/dist/zcap.js +478 -0
  234. package/dist/zcap.js.map +1 -0
  235. package/package.json +109 -0
  236. package/src/auth-header-hooks.ts +163 -0
  237. package/src/authorize.ts +117 -0
  238. package/src/backends/filesystem.ts +3626 -0
  239. package/src/backends/postgres.ts +3073 -0
  240. package/src/backends/postgresSchema.ts +231 -0
  241. package/src/config.default.ts +657 -0
  242. package/src/corsProxy.ts +326 -0
  243. package/src/declarations.d.ts +28 -0
  244. package/src/digest.ts +223 -0
  245. package/src/errors.ts +1122 -0
  246. package/src/index.ts +21 -0
  247. package/src/lib/atomicFile.ts +176 -0
  248. package/src/lib/backendRegistry.ts +139 -0
  249. package/src/lib/backendUsage.ts +79 -0
  250. package/src/lib/backends.ts +351 -0
  251. package/src/lib/blindedIndex.ts +466 -0
  252. package/src/lib/collectionDescription.ts +43 -0
  253. package/src/lib/cursor.ts +52 -0
  254. package/src/lib/edvEnvelope.ts +120 -0
  255. package/src/lib/encryption.ts +472 -0
  256. package/src/lib/etag.ts +40 -0
  257. package/src/lib/exportManifest.ts +115 -0
  258. package/src/lib/importTar.ts +381 -0
  259. package/src/lib/isJson.ts +18 -0
  260. package/src/lib/keyEpoch.ts +89 -0
  261. package/src/lib/keyedMutex.ts +45 -0
  262. package/src/lib/kmsModule.ts +451 -0
  263. package/src/lib/kmsRecordCipher.ts +292 -0
  264. package/src/lib/pagination.ts +22 -0
  265. package/src/lib/paths.ts +382 -0
  266. package/src/lib/preconditions.ts +145 -0
  267. package/src/lib/resourceFileName.ts +69 -0
  268. package/src/lib/revocations.ts +116 -0
  269. package/src/lib/validateDid.ts +41 -0
  270. package/src/lib/validateId.ts +148 -0
  271. package/src/plugin.ts +288 -0
  272. package/src/policy.ts +176 -0
  273. package/src/provisioning.ts +98 -0
  274. package/src/requests/BackendRequest.ts +219 -0
  275. package/src/requests/CollectionRequest.ts +899 -0
  276. package/src/requests/KeyRequest.ts +617 -0
  277. package/src/requests/KeystoreRequest.ts +325 -0
  278. package/src/requests/PolicyRequest.ts +169 -0
  279. package/src/requests/ResourceRequest.ts +649 -0
  280. package/src/requests/RevocationRequest.ts +267 -0
  281. package/src/requests/SpaceRequest.ts +686 -0
  282. package/src/requests/SpacesRepositoryRequest.ts +194 -0
  283. package/src/requests/collectionContext.ts +41 -0
  284. package/src/requests/controllerConsent.ts +108 -0
  285. package/src/requests/keystoreContext.ts +124 -0
  286. package/src/requests/resourceInput.ts +118 -0
  287. package/src/requests/spaceContext.ts +311 -0
  288. package/src/routes.ts +420 -0
  289. package/src/server.ts +69 -0
  290. package/src/start.ts +71 -0
  291. package/src/storage.ts +60 -0
  292. package/src/types.ts +1016 -0
  293. package/src/views/home.hbs +9 -0
  294. package/src/views/templates/main.hbs +12 -0
  295. package/src/zcap.ts +657 -0
@@ -0,0 +1,2164 @@
1
+ /**
2
+ * PostgreSQL persistence backend: stores Spaces, Collections, and Resources as
3
+ * rows (schema in postgresSchema.ts), and WebKMS keystores in a sibling table
4
+ * tree, implementing the same StorageBackend contract as the filesystem
5
+ * backend (types.ts). Selected by configuration (`DATABASE_URL`) and injected
6
+ * the same way (`createApp({ backend })`).
7
+ *
8
+ * Design departures from the filesystem backend (deliberate):
9
+ * - Quota accounting is transactional (`spaces.usage_bytes`, maintained in the
10
+ * same transaction as every write/delete), making the per-Space capacity a
11
+ * HARD limit under concurrency. "Usage" is exactly the stored content bytes;
12
+ * descriptions, policies, and metadata are not counted (a divergence from
13
+ * the filesystem's `du`, which counts every file).
14
+ * - Conditional writes use row locks (`SELECT ... FOR UPDATE`) and
15
+ * transactions instead of the single-process `KeyedMutex`, so two server
16
+ * processes sharing one database get correct conditional writes.
17
+ * - Blobs are buffered single-`bytea` writes bounded by `maxUploadBytes`; an
18
+ * unset cap defaults to `DEFAULT_MAX_UPLOAD_BYTES` rather than "unbounded"
19
+ * (unbounded buffering into a `bytea` is a footgun). Chunked-row streaming
20
+ * is a planned follow-up increment.
21
+ * - `exportSpace` / `importSpace` speak the same tar dialect as the
22
+ * filesystem backend (same file-name codecs, same manifest), so archives
23
+ * migrate between the two backends in either direction; the Postgres import
24
+ * apply loop additionally runs in a single transaction (atomic rollback).
25
+ */
26
+ import { Readable } from 'node:stream';
27
+ import pg from 'pg';
28
+ import pino from 'pino';
29
+ import * as tar from 'tar-stream';
30
+ import YAML from 'yaml';
31
+ import { StorageError, ResourceNotFoundError, SpaceNotFoundError, QuotaExceededError, CountQuotaExceededError, PayloadTooLargeError, PreconditionFailedError, KeystoreStateConflictError, KeyIdConflictError, DuplicateRevocationError } from '../errors.js';
32
+ import { applyMigrations } from './postgresSchema.js';
33
+ import { extractTarEntries, buildImportPlan } from '../lib/importTar.js';
34
+ import { collectionPath, resourcePath } from '../lib/paths.js';
35
+ import { fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js';
36
+ import { sanitizeBackendRecord } from '../lib/backends.js';
37
+ import { backendUsageFields } from '../lib/backendUsage.js';
38
+ import { assertEncryptedWriteConforms } from '../lib/encryption.js';
39
+ import { encodeCursor, decodeCursor } from '../lib/cursor.js';
40
+ import { buildExportManifest } from '../lib/exportManifest.js';
41
+ import { revocationFileName } from '../lib/revocations.js';
42
+ import { isJson } from '../lib/isJson.js';
43
+ import { normalizeDescriptionWrite } from '../lib/collectionDescription.js';
44
+ import { DEFAULT_PAGE_SIZE, clampPageSize } from '../lib/pagination.js';
45
+ import { runBlindedIndexQuery, collectUniqueBlindedTerms, assertNoUniqueBlindedConflict } from '../lib/blindedIndex.js';
46
+ import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_MAX_SPACES_PER_CONTROLLER, DEFAULT_MAX_COLLECTIONS_PER_SPACE, DEFAULT_MAX_RESOURCES_PER_SPACE, normalizeCountLimit } from '../config.default.js';
47
+ import { assertWritePrecondition, assertMetaWritePrecondition, assertCollectionWritePrecondition } from '../lib/preconditions.js';
48
+ /** Pool sizing and per-connection statement timeout (operational defaults). */
49
+ const POOL_MAX = 10;
50
+ const STATEMENT_TIMEOUT_MS = 30_000;
51
+ /**
52
+ * Silent logger used when no logger is injected into the backend (`createApp`
53
+ * wires `fastify.log` in; tests may leave it silent).
54
+ */
55
+ const silentLogger = pino({ level: 'silent' });
56
+ /**
57
+ * Buffers a byte stream fully into memory, aborting with
58
+ * `PayloadTooLargeError` (413) the moment the cumulative size exceeds
59
+ * `maxUploadBytes` -- the buffered-`bytea` analogue of the filesystem
60
+ * backend's streaming `_uploadCapGuard`.
61
+ * @param options {object}
62
+ * @param options.stream {Readable}
63
+ * @param options.maxUploadBytes {number}
64
+ * @param options.backendId {string} for the 413 problem detail
65
+ * @returns {Promise<Buffer>}
66
+ */
67
+ async function bufferStreamCapped({ stream, maxUploadBytes, backendId }) {
68
+ const chunks = [];
69
+ let total = 0;
70
+ for await (const chunk of stream) {
71
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
72
+ total += buffer.length;
73
+ if (total > maxUploadBytes) {
74
+ throw new PayloadTooLargeError({ maxUploadBytes, backendId });
75
+ }
76
+ chunks.push(buffer);
77
+ }
78
+ return Buffer.concat(chunks);
79
+ }
80
+ /**
81
+ * Parses an archived `.meta.<resourceId>.json` sidecar's bytes into the
82
+ * shared sidecar shape; unparseable (or absent) bytes yield `undefined`, and
83
+ * the import falls back to fresh-write defaults.
84
+ * @param bytes {Buffer|undefined}
85
+ * @returns {SidecarShape|undefined}
86
+ */
87
+ function parseSidecar(bytes) {
88
+ if (!bytes) {
89
+ return undefined;
90
+ }
91
+ try {
92
+ return JSON.parse(bytes.toString('utf8'));
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ export class PostgresBackend {
99
+ logger;
100
+ /**
101
+ * Per-Space storage capacity, in bytes (spec "Quotas"). `undefined` means no
102
+ * configured limit. Unlike the filesystem backend's `du`-sampled soft limit,
103
+ * this is enforced transactionally on every content write -- a HARD limit
104
+ * under concurrency.
105
+ */
106
+ capacityBytes;
107
+ /**
108
+ * Largest single upload accepted, in bytes (spec "Quotas",
109
+ * `maxUploadBytes`). Always set on this backend: an unconfigured cap
110
+ * defaults to `DEFAULT_MAX_UPLOAD_BYTES`, because every blob write buffers
111
+ * through memory on the single-`bytea` path.
112
+ */
113
+ maxUploadBytes;
114
+ /**
115
+ * Max Spaces a single controller may create (spec "Quotas", a default-on
116
+ * count quota). `undefined` means no cap. Enforced transactionally on the
117
+ * Space create path (`writeSpace`), serialized per controller by an advisory
118
+ * lock -- a HARD limit under concurrency, like the byte quota. The
119
+ * constructor normalizes an unset option to
120
+ * {@link DEFAULT_MAX_SPACES_PER_CONTROLLER} and a non-finite option
121
+ * (`Infinity`) to `undefined`.
122
+ */
123
+ maxSpacesPerController;
124
+ /**
125
+ * Max Collections a single Space may hold (spec "Quotas", a default-on count
126
+ * quota). `undefined` means no cap. Enforced on the Collection create path,
127
+ * serialized per Space by the space row lock. Normalized like
128
+ * {@link maxSpacesPerController}.
129
+ */
130
+ maxCollectionsPerSpace;
131
+ /**
132
+ * Max live Resources a single Space may hold across all its Collections (spec
133
+ * "Quotas", a default-on count quota). `undefined` means no cap. Enforced on
134
+ * the Resource create path (a tombstone does not count). Normalized like
135
+ * {@link maxSpacesPerController}.
136
+ */
137
+ maxResourcesPerSpace;
138
+ _pool;
139
+ _schema;
140
+ /**
141
+ * @param options {object}
142
+ * @param options.connectionString {string} a `postgres://` URL
143
+ * @param [options.schema] {string} Postgres schema to operate in (set as
144
+ * the connection `search_path`; created by `init()` if absent). Used for
145
+ * test isolation; production uses the default `public`.
146
+ * @param [options.logger] {FastifyBaseLogger}
147
+ * @param [options.capacityBytes] {number} per-Space quota in bytes; a
148
+ * finite value is enforced, `undefined` or a non-finite value means no
149
+ * configured limit
150
+ * @param [options.maxUploadBytes] {number} per-upload cap in bytes;
151
+ * `undefined` applies the `DEFAULT_MAX_UPLOAD_BYTES` default. A non-finite
152
+ * value (`Infinity`, from `MAX_UPLOAD_BYTES=unlimited`) throws: this backend
153
+ * buffers each upload in memory as a single `bytea`, so an unbounded cap is
154
+ * not supported.
155
+ * @param [options.maxSpacesPerController] {number} max Spaces per
156
+ * controller (spec "Quotas"); `undefined` applies the default-on limit,
157
+ * `Infinity` means no cap
158
+ * @param [options.maxCollectionsPerSpace] {number} max Collections per
159
+ * Space; `undefined` applies the default-on limit, `Infinity` means no cap
160
+ * @param [options.maxResourcesPerSpace] {number} max live Resources per
161
+ * Space; `undefined` applies the default-on limit, `Infinity` means no cap
162
+ */
163
+ constructor({ connectionString, schema, logger, capacityBytes, maxUploadBytes, maxSpacesPerController, maxCollectionsPerSpace, maxResourcesPerSpace }) {
164
+ if (schema !== undefined && !/^[a-z_][a-z0-9_]*$/i.test(schema)) {
165
+ throw new Error(`Invalid Postgres schema name: "${schema}".`);
166
+ }
167
+ this._schema = schema;
168
+ this.logger = logger ?? silentLogger;
169
+ // A non-finite `capacityBytes` (`Infinity` from an explicit `unlimited`)
170
+ // behaves exactly like unset inside the backend: no configured limit.
171
+ this.capacityBytes =
172
+ capacityBytes !== undefined && Number.isFinite(capacityBytes)
173
+ ? capacityBytes
174
+ : undefined;
175
+ // This backend buffers each upload in memory as a single `bytea`, so an
176
+ // unbounded per-upload cap is not supported -- fail fast at construction
177
+ // rather than risk an OOM at write time.
178
+ if (maxUploadBytes !== undefined && !Number.isFinite(maxUploadBytes)) {
179
+ throw new Error(`PostgresBackend does not support an unlimited per-upload cap ` +
180
+ `(MAX_UPLOAD_BYTES=unlimited): each upload is buffered in memory as ` +
181
+ `a single bytea. Set MAX_UPLOAD_BYTES to a finite byte count.`);
182
+ }
183
+ this.maxUploadBytes = maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
184
+ // Count quotas normalize like `maxUploadBytes` (unset applies the
185
+ // default-on limit, a non-finite `Infinity` means no cap), so every guard
186
+ // keeps its plain `!== undefined` test.
187
+ this.maxSpacesPerController = normalizeCountLimit(maxSpacesPerController, DEFAULT_MAX_SPACES_PER_CONTROLLER);
188
+ this.maxCollectionsPerSpace = normalizeCountLimit(maxCollectionsPerSpace, DEFAULT_MAX_COLLECTIONS_PER_SPACE);
189
+ this.maxResourcesPerSpace = normalizeCountLimit(maxResourcesPerSpace, DEFAULT_MAX_RESOURCES_PER_SPACE);
190
+ this._pool = new pg.Pool({
191
+ connectionString,
192
+ max: POOL_MAX,
193
+ statement_timeout: STATEMENT_TIMEOUT_MS,
194
+ // `search_path` is a connection-startup parameter, so every pooled
195
+ // connection lands in the right schema with no per-checkout SET race.
196
+ ...(schema !== undefined && { options: `-csearch_path=${schema}` })
197
+ });
198
+ this._pool.on('error', err => {
199
+ this.logger.error({ err }, 'Postgres pool background error');
200
+ });
201
+ }
202
+ /**
203
+ * Connects and applies schema migrations (idempotent, advisory-locked; see
204
+ * postgresSchema.ts). Called once by the `createApp` composition before the
205
+ * server starts listening.
206
+ * @returns {Promise<void>}
207
+ */
208
+ async init() {
209
+ const client = await this._pool.connect();
210
+ try {
211
+ if (this._schema !== undefined) {
212
+ // Identifier-quoted; the constructor validated the name's charset.
213
+ await client.query(`CREATE SCHEMA IF NOT EXISTS "${this._schema}"`);
214
+ }
215
+ // Lift the pool's statement timeout for this session: a waiting
216
+ // instance blocks on the migration advisory lock for as long as the
217
+ // holder's migration takes, and a future slow migration must not be
218
+ // capped at the request-path timeout either.
219
+ await client.query('SET statement_timeout = 0');
220
+ await applyMigrations({ client });
221
+ }
222
+ finally {
223
+ // Destroy rather than pool-return the client, so the lifted timeout
224
+ // never leaks into a request-path connection.
225
+ client.release(true);
226
+ }
227
+ }
228
+ /**
229
+ * Drains the connection pool. Wired to the Fastify `onClose` hook by the
230
+ * plugin composition.
231
+ * @returns {Promise<void>}
232
+ */
233
+ async close() {
234
+ await this._pool.end();
235
+ }
236
+ /**
237
+ * Runs `fn` inside one transaction on a dedicated client, committing on
238
+ * success and rolling back on any throw.
239
+ * @param fn {(client: pg.PoolClient) => Promise<T>}
240
+ * @returns {Promise<T>}
241
+ */
242
+ async _withTransaction(fn) {
243
+ const client = await this._pool.connect();
244
+ try {
245
+ await client.query('BEGIN');
246
+ const result = await fn(client);
247
+ await client.query('COMMIT');
248
+ return result;
249
+ }
250
+ catch (err) {
251
+ try {
252
+ await client.query('ROLLBACK');
253
+ }
254
+ catch (rollbackErr) {
255
+ this.logger.error({ err: rollbackErr }, 'Postgres rollback failed');
256
+ }
257
+ throw err;
258
+ }
259
+ finally {
260
+ client.release();
261
+ }
262
+ }
263
+ /**
264
+ * Self-description advertised at `GET /space/:spaceId/backends`. Same
265
+ * affordances as the filesystem backend: conditional writes (ETag
266
+ * validators, row-locked preconditions) and the `changes` and
267
+ * `blinded-index` query profiles.
268
+ * @returns {Required<Omit<BackendDescriptor, 'provider' | 'connection'>>}
269
+ */
270
+ describe() {
271
+ return {
272
+ id: 'default',
273
+ name: 'Server PostgreSQL',
274
+ managedBy: 'server',
275
+ storageMode: ['document', 'blob'],
276
+ persistence: 'durable',
277
+ // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
278
+ // keys on the `encryption` marker, a client-declared `epoch` stamp on
279
+ // Resources, and conditional (`If-Match`) Collection Description writes.
280
+ features: [
281
+ 'conditional-writes',
282
+ 'changes-query',
283
+ 'blinded-index-query',
284
+ 'key-epochs'
285
+ ]
286
+ };
287
+ }
288
+ /**
289
+ * Ensures the `spaces` row for `spaceId` exists (a placeholder with a NULL
290
+ * description when the Space was never described -- the analogue of the
291
+ * filesystem creating a Space directory on a sub-Space write).
292
+ * @param options {object}
293
+ * @param options.client {pg.PoolClient}
294
+ * @param options.spaceId {string}
295
+ * @returns {Promise<void>}
296
+ */
297
+ async _ensureSpaceRow({ client, spaceId }) {
298
+ await client.query(`INSERT INTO spaces (space_id) VALUES ($1)
299
+ ON CONFLICT (space_id) DO NOTHING`, [spaceId]);
300
+ }
301
+ /**
302
+ * Ensures the `collections` row (and its parent `spaces` row) exists,
303
+ * placeholder-description like `_ensureSpaceRow`.
304
+ * @param options {object}
305
+ * @param options.client {pg.PoolClient}
306
+ * @param options.spaceId {string}
307
+ * @param options.collectionId {string}
308
+ * @returns {Promise<void>}
309
+ */
310
+ async _ensureCollectionRow({ client, spaceId, collectionId }) {
311
+ await this._ensureSpaceRow({ client, spaceId });
312
+ await client.query(`INSERT INTO collections (space_id, collection_id) VALUES ($1, $2)
313
+ ON CONFLICT (space_id, collection_id) DO NOTHING`, [spaceId, collectionId]);
314
+ }
315
+ /**
316
+ * Applies a usage delta to the Space's transactional quota counter,
317
+ * enforcing the configured capacity in the same statement (the hard-limit
318
+ * departure from the filesystem's `du`-sampled soft check). Zero rows
319
+ * updated with the row present means the write would not fit:
320
+ * `QuotaExceededError` (507), rolling back the enclosing transaction.
321
+ * MUST run inside the same transaction as the content mutation.
322
+ * @param options {object}
323
+ * @param options.client {pg.PoolClient}
324
+ * @param options.spaceId {string}
325
+ * @param options.delta {number} signed byte delta (new minus old size)
326
+ * @returns {Promise<void>}
327
+ */
328
+ async _applyUsageDelta({ client, spaceId, delta }) {
329
+ // Only a growing write can exhaust the quota; shrinking writes and deletes
330
+ // always apply (and clamp at zero so drift can never go negative).
331
+ const cap = delta > 0 ? (this.capacityBytes ?? null) : null;
332
+ const result = await client.query(`UPDATE spaces
333
+ SET usage_bytes = GREATEST(usage_bytes + $2, 0)
334
+ WHERE space_id = $1
335
+ AND ($3::bigint IS NULL OR usage_bytes + $2 <= $3::bigint)`, [spaceId, delta, cap]);
336
+ if (result.rowCount === 0) {
337
+ throw new QuotaExceededError({
338
+ spaceId,
339
+ capacityBytes: this.capacityBytes
340
+ });
341
+ }
342
+ }
343
+ // Quotas
344
+ /**
345
+ * Reports the Space's usage from the transactional counter (no measurement
346
+ * pass, no cache). The per-Collection breakdown is computed on demand with
347
+ * one aggregate query.
348
+ * @param options {object}
349
+ * @param options.spaceId {string}
350
+ * @param [options.includeCollections] {boolean}
351
+ * @returns {Promise<BackendUsage>}
352
+ */
353
+ async reportUsage({ spaceId, includeCollections = false }) {
354
+ const measuredAt = new Date().toISOString();
355
+ const { rows } = await this._pool.query('SELECT usage_bytes FROM spaces WHERE space_id = $1', [spaceId]);
356
+ const usageBytes = rows[0] ? Number(rows[0].usage_bytes) : 0;
357
+ let usageByCollection;
358
+ if (includeCollections) {
359
+ const { rows: collectionRows } = await this._pool.query(`SELECT collection_id, COALESCE(SUM(size_bytes), 0) AS usage
360
+ FROM resources
361
+ WHERE space_id = $1
362
+ GROUP BY collection_id
363
+ ORDER BY collection_id`, [spaceId]);
364
+ usageByCollection = collectionRows.map(row => ({
365
+ id: row.collection_id,
366
+ usageBytes: Number(row.usage)
367
+ }));
368
+ }
369
+ return {
370
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes: usageBytes }),
371
+ measuredAt,
372
+ ...(includeCollections && { usageByCollection })
373
+ };
374
+ }
375
+ /**
376
+ * Reports a single Collection's usage (its `SUM(size_bytes)` slice), with
377
+ * `state` / `limit` derived from the Space total (the quota is a per-Space
378
+ * limit).
379
+ * @param options {object}
380
+ * @param options.spaceId {string}
381
+ * @param options.collectionId {string}
382
+ * @returns {Promise<BackendUsage>}
383
+ */
384
+ async reportCollectionUsage({ spaceId, collectionId }) {
385
+ const measuredAt = new Date().toISOString();
386
+ const { rows } = await this._pool.query(`SELECT
387
+ (SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
388
+ AS space_total,
389
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
390
+ WHERE space_id = $1 AND collection_id = $2) AS collection_total`, [spaceId, collectionId]);
391
+ const spaceTotalBytes = Number(rows[0]?.space_total ?? 0);
392
+ const usageBytes = Number(rows[0]?.collection_total ?? 0);
393
+ return {
394
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes }),
395
+ measuredAt
396
+ };
397
+ }
398
+ /**
399
+ * Builds the shared quota-report condition fields (see
400
+ * `lib/backendUsage.ts`).
401
+ * @param options {object}
402
+ * @param options.usageBytes {number}
403
+ * @param options.spaceTotalBytes {number}
404
+ * @returns {Omit<BackendUsage, 'measuredAt' | 'usageByCollection'>}
405
+ */
406
+ _backendUsageFields({ usageBytes, spaceTotalBytes }) {
407
+ const { id, name, managedBy } = this.describe();
408
+ return backendUsageFields({
409
+ usageBytes,
410
+ spaceTotalBytes,
411
+ capacityBytes: this.capacityBytes,
412
+ maxUploadBytes: this.maxUploadBytes,
413
+ id,
414
+ name,
415
+ managedBy
416
+ });
417
+ }
418
+ // Spaces
419
+ /**
420
+ * @param options {object}
421
+ * @param options.spaceId {string}
422
+ * @param options.spaceDescription {SpaceDescription}
423
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
424
+ * Space's `createdBy` on first write only
425
+ * @returns {Promise<void>}
426
+ */
427
+ async writeSpace({ spaceId, spaceDescription, createdBy }) {
428
+ const { controller } = spaceDescription;
429
+ // `createdBy` names the Space's creator, not its last writer: taken from
430
+ // this write's invoker only when there is no prior description, and
431
+ // preserved verbatim afterward. The client-supplied `spaceDescription` is
432
+ // wire input and may carry its own `createdBy` -- discard it, since the
433
+ // server alone is authoritative for this field.
434
+ const { createdBy: _suppliedCreatedBy, ...rest } = spaceDescription;
435
+ const descriptionJson = JSON.stringify(rest);
436
+ // The upsert maintains the denormalized `controller` column on both insert
437
+ // and update -- the description's controller can change on update, and the
438
+ // Spaces count quota reads this column (spec "Quotas"). `createdBy` is
439
+ // resolved within this same statement, in one transaction-free round trip
440
+ // (no separate read-then-write race with a concurrent `writeSpace` for the
441
+ // same id):
442
+ // - `spaces.description IS NULL` means there is no prior description row
443
+ // (a placeholder row created by a sub-resource write before any Space
444
+ // Description was written) -- this write IS the create, so it behaves
445
+ // like the insert branch: attach this write's `createdBy` when present,
446
+ // otherwise omit the key entirely (never store it as JSON `null`).
447
+ // - Otherwise a prior description exists, and its `createdBy` -- present
448
+ // or absent -- is preserved verbatim via the jsonb `?` key-existence
449
+ // operator; this write's `createdBy` is ignored entirely (never
450
+ // backfilled).
451
+ const upsert = (queryable) => queryable.query(`INSERT INTO spaces (space_id, description, controller)
452
+ VALUES (
453
+ $1,
454
+ CASE WHEN $4::text IS NULL THEN $2::jsonb
455
+ ELSE ($2::jsonb) || jsonb_build_object('createdBy', $4::text) END,
456
+ $3
457
+ )
458
+ ON CONFLICT (space_id) DO UPDATE SET
459
+ description = CASE
460
+ WHEN spaces.description IS NULL THEN
461
+ CASE WHEN $4::text IS NULL THEN $2::jsonb
462
+ ELSE ($2::jsonb) || jsonb_build_object('createdBy', $4::text) END
463
+ WHEN spaces.description ? 'createdBy' THEN
464
+ ($2::jsonb) || jsonb_build_object(
465
+ 'createdBy', spaces.description->>'createdBy'
466
+ )
467
+ ELSE $2::jsonb
468
+ END,
469
+ controller = EXCLUDED.controller`, [spaceId, descriptionJson, controller, createdBy ?? null]);
470
+ if (this.maxSpacesPerController === undefined) {
471
+ await upsert(this._pool);
472
+ return;
473
+ }
474
+ // Count quota (create path only), enforced as a HARD limit: take a
475
+ // controller-scoped advisory lock so concurrent creates for the same
476
+ // controller serialize (the byte quota's posture), detect a create (no
477
+ // described row yet -- a NULL-description placeholder counts as a create),
478
+ // COUNT this controller's Spaces, and reject at the limit.
479
+ await this._withTransaction(async (client) => {
480
+ await client.query(`SELECT pg_advisory_xact_lock(hashtext('controller-count:' || $1))`, [controller]);
481
+ const { rows } = await client.query('SELECT description FROM spaces WHERE space_id = $1', [spaceId]);
482
+ const isCreate = rows[0]?.description == null;
483
+ if (isCreate) {
484
+ const { rows: countRows } = await client.query('SELECT COUNT(*)::int AS count FROM spaces WHERE controller = $1', [controller]);
485
+ if (countRows[0].count >= this.maxSpacesPerController) {
486
+ throw new CountQuotaExceededError({
487
+ scope: 'Spaces per controller',
488
+ limit: this.maxSpacesPerController
489
+ });
490
+ }
491
+ }
492
+ await upsert(client);
493
+ });
494
+ }
495
+ /**
496
+ * @param options {object}
497
+ * @param options.spaceId {string}
498
+ * @returns {Promise<SpaceDescription|undefined>} falsy when the Space does
499
+ * not exist or is a placeholder row without a description
500
+ */
501
+ async getSpaceDescription({ spaceId }) {
502
+ const { rows } = await this._pool.query('SELECT description FROM spaces WHERE space_id = $1', [spaceId]);
503
+ return rows[0]?.description ?? undefined;
504
+ }
505
+ /**
506
+ * Deletes the Space row; collections, resources, policies, and backend
507
+ * records go with it via `ON DELETE CASCADE` (keystores are a sibling tree
508
+ * and are deliberately untouched). Idempotent.
509
+ * @param options {object}
510
+ * @param options.spaceId {string}
511
+ * @returns {Promise<void>}
512
+ */
513
+ async deleteSpace({ spaceId }) {
514
+ await this._pool.query('DELETE FROM spaces WHERE space_id = $1', [spaceId]);
515
+ }
516
+ /**
517
+ * Every described Space, sorted by id (byte order via `COLLATE "C"`).
518
+ * Placeholder rows without a description are skipped, like a Space dir
519
+ * without a readable description file.
520
+ * @returns {Promise<SpaceDescription[]>}
521
+ */
522
+ async listSpaces() {
523
+ const { rows } = await this._pool.query(`SELECT description FROM spaces
524
+ WHERE description IS NOT NULL
525
+ ORDER BY space_id`);
526
+ return rows.map(row => row.description);
527
+ }
528
+ // Collections
529
+ /**
530
+ * @param options {object}
531
+ * @param options.spaceId {string}
532
+ * @param options.collectionId {string}
533
+ * @param options.collectionDescription {CollectionDescription}
534
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
535
+ * Collection's `createdBy` on first write only
536
+ * @param [options.ifMatch] {string} an `If-Match` compare-and-swap on the
537
+ * current description version (the `key-epochs` feature); a stale validator
538
+ * throws `PreconditionFailedError` (412)
539
+ * @returns {Promise<{ version: number }>} the Collection's new description
540
+ * version (the `ETag` validator)
541
+ */
542
+ async writeCollection({ spaceId, collectionId, collectionDescription, createdBy, ifMatch, assertTransition }) {
543
+ return this._withTransaction(async (client) => {
544
+ await this._ensureSpaceRow({ client, spaceId });
545
+ // Serialize all Collection writes within the Space on its space row: the
546
+ // collection-row `FOR UPDATE` below locks nothing when the row does not
547
+ // exist yet, so without this two concurrent creates of *different* new
548
+ // ids could each pass the create-path quota COUNT (overshooting
549
+ // `maxCollectionsPerSpace`), and two creates of the *same* id could each
550
+ // compute the same first version.
551
+ await client.query('SELECT 1 FROM spaces WHERE space_id = $1 FOR UPDATE', [spaceId]);
552
+ // Lock the Collection row (if any) and read its current description and
553
+ // version, so the `If-Match` compare-and-swap, the transition rails, the
554
+ // create detection, and the monotonic version bump are all atomic with
555
+ // the write (two concurrent recipient edits cannot clobber one another).
556
+ const { rows } = await client.query(`SELECT description, description_version FROM collections
557
+ WHERE space_id = $1 AND collection_id = $2 FOR UPDATE`, [spaceId, collectionId]);
558
+ const priorRow = rows[0];
559
+ // A missing row and a placeholder (NULL-description) row are both "no
560
+ // described Collection yet": version 0, so the first real description
561
+ // write returns 1 (a placeholder's `description_version` column holds
562
+ // the schema DEFAULT and must not count).
563
+ const prior = priorRow?.description == null
564
+ ? undefined
565
+ : {
566
+ ...priorRow.description,
567
+ descriptionVersion: priorRow.description_version
568
+ };
569
+ const currentVersion = prior?.descriptionVersion ?? 0;
570
+ // Compare-and-swap (opt-in): a stale `If-Match` throws 412. An
571
+ // unconditional write skips this.
572
+ assertCollectionWritePrecondition({
573
+ collectionId,
574
+ currentVersion,
575
+ ifMatch
576
+ });
577
+ // The request layer's state-transition rails (e.g. epoch append-only),
578
+ // re-evaluated here against the row just read under the lock.
579
+ assertTransition?.(prior);
580
+ // Count quota (create path only): a create is no row or a placeholder
581
+ // (NULL-description) row; describing one must not push the Space past
582
+ // `maxCollectionsPerSpace` (spec "Quotas").
583
+ if (this.maxCollectionsPerSpace !== undefined && prior === undefined) {
584
+ const { rows: countRows } = await client.query('SELECT COUNT(*)::int AS count FROM collections WHERE space_id = $1', [spaceId]);
585
+ if (countRows[0].count >= this.maxCollectionsPerSpace) {
586
+ throw new CountQuotaExceededError({
587
+ scope: 'Collections per Space',
588
+ limit: this.maxCollectionsPerSpace
589
+ });
590
+ }
591
+ }
592
+ const version = currentVersion + 1;
593
+ await this._upsertCollection({
594
+ queryable: client,
595
+ spaceId,
596
+ collectionId,
597
+ collectionDescription,
598
+ createdBy,
599
+ descriptionVersion: version
600
+ });
601
+ return { version };
602
+ });
603
+ }
604
+ /**
605
+ * The one Collection-description upsert statement, shared by
606
+ * `writeCollection` and the import apply loop. `createdBy` is resolved
607
+ * within this same statement, in one round trip (no separate read-then-write
608
+ * race with a concurrent write for the same id):
609
+ * - `collections.description IS NULL` means there is no prior description
610
+ * row (a placeholder row created by a sub-Resource write before any
611
+ * Collection Description was written) -- this write IS the create, so it
612
+ * behaves like the insert branch: attach the `createdBy` parameter when
613
+ * present, otherwise omit the key entirely (never store it as JSON
614
+ * `null`).
615
+ * - Otherwise a prior description exists, and its `createdBy` -- present or
616
+ * absent -- is preserved verbatim via the jsonb `?` key-existence
617
+ * operator; the `createdBy` parameter is ignored entirely (never
618
+ * backfilled).
619
+ * Any `createdBy` embedded in `collectionDescription` itself is discarded
620
+ * here: `writeCollection` passes the invoker DID via the `createdBy`
621
+ * parameter instead, and the import apply loop passes the imported
622
+ * document's own `createdBy` so a restored Collection keeps its original
623
+ * creator (that import call is always a create -- the caller skips existing
624
+ * Collections -- so there is no prior row to preserve instead).
625
+ * @param options {object}
626
+ * @param options.queryable {Queryable}
627
+ * @param options.spaceId {string}
628
+ * @param options.collectionId {string}
629
+ * @param options.collectionDescription {CollectionDescription}
630
+ * @param [options.createdBy] {string} resolved creator to fall back to
631
+ * when there is no prior row
632
+ * @param [options.descriptionVersion] {number} the monotonic description
633
+ * version to stamp (the `ETag` validator; the `key-epochs` feature), kept
634
+ * OUT of the `description` jsonb -- it lives in the `description_version`
635
+ * column and travels only as the `ETag` header. When omitted (the import
636
+ * path), a `_version` on the incoming archived description is used, else 1.
637
+ * @returns {Promise<void>}
638
+ */
639
+ async _upsertCollection({ queryable, spaceId, collectionId, collectionDescription, createdBy, descriptionVersion }) {
640
+ // Shared normalization (lib/collectionDescription.ts): strip the
641
+ // version-bearing members from the incoming (possibly imported)
642
+ // description so neither lands in the stored jsonb body -- the resolved
643
+ // version becomes the `description_version` column instead. `createdBy` is
644
+ // additionally stripped here because this statement re-resolves it in SQL.
645
+ const { body, version } = normalizeDescriptionWrite({
646
+ collectionDescription,
647
+ descriptionVersion
648
+ });
649
+ const { createdBy: _suppliedCreatedBy, ...rest } = body;
650
+ await queryable.query(`INSERT INTO collections (space_id, collection_id, description,
651
+ description_version)
652
+ VALUES (
653
+ $1, $2,
654
+ CASE WHEN $4::text IS NULL THEN $3::jsonb
655
+ ELSE ($3::jsonb) || jsonb_build_object('createdBy', $4::text) END,
656
+ $5
657
+ )
658
+ ON CONFLICT (space_id, collection_id) DO UPDATE SET
659
+ description = CASE
660
+ WHEN collections.description IS NULL THEN
661
+ CASE WHEN $4::text IS NULL THEN $3::jsonb
662
+ ELSE ($3::jsonb) || jsonb_build_object('createdBy', $4::text) END
663
+ WHEN collections.description ? 'createdBy' THEN
664
+ ($3::jsonb) || jsonb_build_object(
665
+ 'createdBy', collections.description->>'createdBy'
666
+ )
667
+ ELSE $3::jsonb
668
+ END,
669
+ description_version = $5`, [spaceId, collectionId, JSON.stringify(rest), createdBy ?? null, version]);
670
+ }
671
+ /**
672
+ * @param options {object}
673
+ * @param options.spaceId {string}
674
+ * @param options.collectionId {string}
675
+ * @returns {Promise<(CollectionDescription & { descriptionVersion?: number })
676
+ * | undefined>} `descriptionVersion` is the out-of-band `ETag` validator.
677
+ */
678
+ async getCollectionDescription({ spaceId, collectionId }) {
679
+ const { rows } = await this._pool.query(`SELECT description, description_version FROM collections
680
+ WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
681
+ const row = rows[0];
682
+ if (!row?.description) {
683
+ return undefined;
684
+ }
685
+ // Surface the version out-of-band as `descriptionVersion` (the handler sets
686
+ // the `ETag` header from it); it is stored in its own column, never the
687
+ // wire body.
688
+ return { ...row.description, descriptionVersion: row.description_version };
689
+ }
690
+ /**
691
+ * Deletes the Collection row (resources cascade), its policies, and
692
+ * subtracts the Collection's stored bytes from the Space usage counter --
693
+ * all in one transaction. Idempotent.
694
+ * @param options {object}
695
+ * @param options.spaceId {string}
696
+ * @param options.collectionId {string}
697
+ * @returns {Promise<void>}
698
+ */
699
+ async deleteCollection({ spaceId, collectionId }) {
700
+ await this._withTransaction(async (client) => {
701
+ const { rows } = await client.query(`SELECT COALESCE(SUM(size_bytes), 0) AS total FROM resources
702
+ WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
703
+ const freedBytes = Number(rows[0]?.total ?? 0);
704
+ await client.query(`DELETE FROM collections WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
705
+ // Collection- and Resource-level policies live under the Collection (the
706
+ // filesystem removes them with the dir; here they key off collection_id).
707
+ await client.query(`DELETE FROM policies WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
708
+ if (freedBytes > 0) {
709
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
710
+ }
711
+ });
712
+ }
713
+ /**
714
+ * Every Collection row in the Space, sorted by id. A row without a
715
+ * description (created by a sub-Collection write) falls back to the id for
716
+ * its `name`, like a description-less directory on the filesystem.
717
+ * @param options {object}
718
+ * @param options.spaceId {string}
719
+ * @returns {Promise<CollectionSummary[]>}
720
+ */
721
+ async listCollections({ spaceId }) {
722
+ const { rows } = await this._pool.query(`SELECT collection_id, description FROM collections
723
+ WHERE space_id = $1 ORDER BY collection_id`, [spaceId]);
724
+ return rows.map(row => ({
725
+ id: row.collection_id,
726
+ url: collectionPath({ spaceId, collectionId: row.collection_id }),
727
+ name: row.description?.name ?? row.collection_id
728
+ }));
729
+ }
730
+ /**
731
+ * Lists a Collection's Resources, cursor-paginated with the same keyset
732
+ * (ascending `resourceId`, byte order), cursor codec, clamps, and `next`
733
+ * construction as the filesystem backend. Tombstones are invisible.
734
+ * @param options {object}
735
+ * @param options.spaceId {string}
736
+ * @param options.collectionId {string}
737
+ * @param [options.limit] {number}
738
+ * @param [options.cursor] {string}
739
+ * @param [options.collectionDescription] {CollectionDescription}
740
+ * @returns {Promise<CollectionResourcesList>}
741
+ */
742
+ async listCollectionItems({ spaceId, collectionId, limit, cursor, collectionDescription: providedDescription }) {
743
+ const collectionDescription = providedDescription ??
744
+ (await this.getCollectionDescription({ spaceId, collectionId }));
745
+ const after = cursor !== undefined ? decodeCursor(cursor).after : undefined;
746
+ const pageSize = limit === undefined ? DEFAULT_PAGE_SIZE : clampPageSize(limit);
747
+ const { rows: countRows } = await this._pool.query(`SELECT COUNT(*) AS total FROM resources
748
+ WHERE space_id = $1 AND collection_id = $2 AND NOT deleted`, [spaceId, collectionId]);
749
+ const totalItems = Number(countRows[0]?.total ?? 0);
750
+ // Take `pageSize + 1` from the seek point to detect a further page without
751
+ // a second query; `hasMore` is whether the extra row arrived.
752
+ const { rows } = await this._pool.query(`SELECT resource_id, content_type, custom, epoch FROM resources
753
+ WHERE space_id = $1 AND collection_id = $2 AND NOT deleted
754
+ AND ($3::text IS NULL OR resource_id > $3)
755
+ ORDER BY resource_id
756
+ LIMIT $4`, [spaceId, collectionId, after ?? null, pageSize + 1]);
757
+ const hasMore = rows.length > pageSize;
758
+ const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
759
+ // Surface the user-writable `custom.name` only for a plaintext Collection;
760
+ // on an encrypted one `custom` is the opaque envelope, so the listing
761
+ // omits `name` (spec "List Collection", encrypted-Collection note).
762
+ const encrypted = collectionDescription?.encryption !== undefined;
763
+ const items = pageRows.map(row => {
764
+ const name = encrypted ? undefined : row.custom?.name;
765
+ return {
766
+ id: row.resource_id,
767
+ url: resourcePath({
768
+ spaceId,
769
+ collectionId,
770
+ resourceId: row.resource_id
771
+ }),
772
+ contentType: row.content_type,
773
+ ...(name !== undefined && { name }),
774
+ // The client-declared key epoch (the `key-epochs` feature) rides each
775
+ // listing item so a reader picks the right epoch key without a `/meta`
776
+ // fetch per Resource.
777
+ ...(row.epoch !== null && { epoch: row.epoch })
778
+ };
779
+ });
780
+ let next;
781
+ if (hasMore) {
782
+ const lastId = pageRows[pageRows.length - 1].resource_id;
783
+ const base = collectionPath({
784
+ spaceId,
785
+ collectionId,
786
+ trailingSlash: true
787
+ });
788
+ next = `${base}?limit=${pageSize}&cursor=${encodeCursor(lastId)}`;
789
+ }
790
+ return {
791
+ id: collectionId,
792
+ url: collectionPath({ spaceId, collectionId }),
793
+ name: collectionDescription?.name ?? collectionId,
794
+ type: collectionDescription?.type || ['Collection'],
795
+ totalItems,
796
+ items,
797
+ ...(next !== undefined && { next })
798
+ };
799
+ }
800
+ // Resources
801
+ /**
802
+ * Writes a Resource representation as one transaction: row lock, shared
803
+ * precondition evaluation (exact filesystem semantics -- a tombstone counts
804
+ * as "not exists", `ifNoneMatch` precedence per RFC9110), monotonic
805
+ * `version` bump continuing through delete/recreate, and the transactional
806
+ * quota delta. JSON is stored as its serialized UTF-8 bytes; blobs buffer
807
+ * through the capped accumulator.
808
+ * @param options {object}
809
+ * @param options.spaceId {string}
810
+ * @param options.collectionId {string}
811
+ * @param options.resourceId {string}
812
+ * @param options.input {ResourceInput}
813
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
814
+ * Resource's `createdBy` on first write only
815
+ * @param [options.ifMatch] {string}
816
+ * @param [options.ifNoneMatch] {boolean}
817
+ * @returns {Promise<{ version: number }>}
818
+ */
819
+ async writeResource({ spaceId, collectionId, resourceId, input, createdBy, epoch, ifMatch, ifNoneMatch }) {
820
+ const { maxUploadBytes } = this;
821
+ let content;
822
+ if (input.kind === 'json') {
823
+ content = Buffer.from(JSON.stringify(input.data));
824
+ if (content.length > maxUploadBytes) {
825
+ throw new PayloadTooLargeError({
826
+ maxUploadBytes,
827
+ backendId: this.describe().id,
828
+ uploadBytes: content.length
829
+ });
830
+ }
831
+ }
832
+ else {
833
+ // Pre-flight a declared size, then buffer through the counting guard
834
+ // that hard-caps a body whose size is omitted or understated. Buffering
835
+ // happens BEFORE the transaction so a slow upload holds no row lock.
836
+ if (input.declaredBytes !== undefined &&
837
+ input.declaredBytes > maxUploadBytes) {
838
+ throw new PayloadTooLargeError({
839
+ maxUploadBytes,
840
+ backendId: this.describe().id,
841
+ uploadBytes: input.declaredBytes
842
+ });
843
+ }
844
+ content = await bufferStreamCapped({
845
+ stream: input.stream,
846
+ maxUploadBytes,
847
+ backendId: this.describe().id
848
+ });
849
+ }
850
+ return this._withTransaction(async (client) => {
851
+ await this._ensureCollectionRow({ client, spaceId, collectionId });
852
+ // The EDV unique-attribute invariant (`unique: true` blinded attributes;
853
+ // the `blinded-index-query` feature): a write claiming a unique blinded
854
+ // triple must not collide with another live document's claim. Only a
855
+ // unique-carrying JSON write can create a new claim, so only such writes
856
+ // pay for it: a per-Collection transaction-scoped advisory lock
857
+ // serializes the claimants (held to commit, so the loser's scan sees the
858
+ // winner's committed row) without entering the row-lock ordering of
859
+ // plain writes, then the conflict is evaluated against the Collection's
860
+ // other live JSON documents.
861
+ if (input.kind === 'json' &&
862
+ collectUniqueBlindedTerms({ document: input.data }).length > 0) {
863
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [spaceId, collectionId]);
864
+ const { rows: candidateRows } = await client.query(`SELECT resource_id, content FROM resources
865
+ WHERE space_id = $1 AND collection_id = $2 AND is_json
866
+ AND NOT deleted AND resource_id <> $3`, [spaceId, collectionId, resourceId]);
867
+ const candidates = [];
868
+ for (const row of candidateRows) {
869
+ if (!row.content) {
870
+ continue;
871
+ }
872
+ try {
873
+ candidates.push({
874
+ resourceId: row.resource_id,
875
+ document: JSON.parse(row.content.toString('utf8'))
876
+ });
877
+ }
878
+ catch {
879
+ // skip an unparsable body
880
+ }
881
+ }
882
+ assertNoUniqueBlindedConflict({ document: input.data, candidates });
883
+ }
884
+ // Narrow projection: the lock needs the row, not its (possibly multi-MB)
885
+ // `content` bytea, which this path never reads.
886
+ const { rows } = await client.query(`SELECT version, size_bytes, deleted, created_at, created_by
887
+ FROM resources
888
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
889
+ FOR UPDATE`, [spaceId, collectionId, resourceId]);
890
+ const prior = rows[0];
891
+ const exists = prior !== undefined && !prior.deleted;
892
+ if (ifMatch !== undefined || ifNoneMatch) {
893
+ assertWritePrecondition({
894
+ resourceId,
895
+ exists,
896
+ currentVersion: prior?.version ?? 0,
897
+ ifMatch,
898
+ ifNoneMatch
899
+ });
900
+ }
901
+ // Count quota (create path only): a new live Resource -- including one
902
+ // written over a tombstone (`exists` is false) -- must not push the Space
903
+ // past `maxResourcesPerSpace` (spec "Quotas"). An overwrite of a live
904
+ // Resource never trips it. Counted inside the write transaction.
905
+ if (this.maxResourcesPerSpace !== undefined && !exists) {
906
+ const { rows: countRows } = await client.query(`SELECT COUNT(*)::int AS count FROM resources
907
+ WHERE space_id = $1 AND NOT deleted`, [spaceId]);
908
+ if (countRows[0].count >= this.maxResourcesPerSpace) {
909
+ throw new CountQuotaExceededError({
910
+ scope: 'Resources per Space',
911
+ limit: this.maxResourcesPerSpace
912
+ });
913
+ }
914
+ }
915
+ const now = new Date().toISOString();
916
+ const version = (prior?.version ?? 0) + 1;
917
+ const priorSize = exists ? Number(prior.size_bytes) : 0;
918
+ const delta = content.length - priorSize;
919
+ if (delta !== 0) {
920
+ await this._applyUsageDelta({ client, spaceId, delta });
921
+ }
922
+ // A content write preserves the independent `metaVersion` and the
923
+ // user-writable `custom` of a LIVE Resource; a tombstoned row already
924
+ // dropped both (the metadata went with the deleted Resource).
925
+ //
926
+ // Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
927
+ // (a tombstone is a real row and stays lock-serialized), the SELECT FOR
928
+ // UPDATE locked nothing -- READ COMMITTED has no gap locks -- so a
929
+ // concurrent creator may have raced past the same precondition. A plain
930
+ // INSERT (no ON CONFLICT) makes the primary key the arbiter: the loser's
931
+ // unique violation maps to the 412 the precondition would have thrown.
932
+ // `createdBy` names the Resource's creator, not its last writer: taken
933
+ // from this write's invoker only when there is no prior row at all
934
+ // (`prior === undefined`), then preserved EXACTLY as the prior row has
935
+ // it -- including preserved-as-absent -- by every later write,
936
+ // regardless of who invokes it. A tombstone IS a prior row, so a
937
+ // re-create over one keeps the tombstone's `createdBy` (or its
938
+ // absence), exactly as `created_at` is preserved across it.
939
+ const creator = prior !== undefined ? prior.created_by : (createdBy ?? null);
940
+ const values = [
941
+ spaceId,
942
+ collectionId,
943
+ resourceId,
944
+ input.contentType,
945
+ content,
946
+ isJson({ contentType: input.contentType }),
947
+ content.length,
948
+ version,
949
+ prior?.created_at ?? now,
950
+ creator,
951
+ // The client-declared key epoch (the `key-epochs` feature): a content
952
+ // write stores it and CLEARS it when absent (the new ciphertext's epoch
953
+ // is unknown), so both the INSERT and the conflict update set it from
954
+ // this write -- it is NOT preserved from the prior row like `created_by`.
955
+ epoch ?? null
956
+ ];
957
+ const insertSql = `
958
+ INSERT INTO resources (
959
+ space_id, collection_id, resource_id, content_type, content,
960
+ is_json, size_bytes, version, meta_version, custom, deleted,
961
+ created_at, updated_at, created_by, epoch
962
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, NULL, false, $9, $9, $10, $11)`;
963
+ if (ifNoneMatch && prior === undefined) {
964
+ try {
965
+ await client.query(insertSql, values);
966
+ }
967
+ catch (err) {
968
+ if (err.code === '23505') {
969
+ throw new PreconditionFailedError({
970
+ detail: `Resource '${resourceId}' already exists (If-None-Match: *).`
971
+ });
972
+ }
973
+ throw err;
974
+ }
975
+ }
976
+ else {
977
+ // The conflict update derives `version` from the row (`resources.
978
+ // version + 1`), not from the pre-read: if a concurrent creator
979
+ // slipped in after our lock-nothing SELECT, the counter still
980
+ // advances monotonically instead of two writers both claiming
981
+ // version 1 (an ETag anomaly). RETURNING reports the version that
982
+ // actually landed.
983
+ const { rows: written } = await client.query(`${insertSql}
984
+ ON CONFLICT (space_id, collection_id, resource_id) DO UPDATE SET
985
+ content_type = EXCLUDED.content_type,
986
+ content = EXCLUDED.content,
987
+ is_json = EXCLUDED.is_json,
988
+ size_bytes = EXCLUDED.size_bytes,
989
+ version = resources.version + 1,
990
+ deleted = false,
991
+ updated_at = EXCLUDED.updated_at,
992
+ created_by = resources.created_by,
993
+ epoch = EXCLUDED.epoch
994
+ RETURNING version`, values);
995
+ return { version: written[0].version };
996
+ }
997
+ // `created_at` / `meta_version` / `custom` are deliberately NOT in the
998
+ // conflict update: an overwrite keeps the original creation time (also
999
+ // across a tombstone, as the filesystem sidecar does) and the metadata
1000
+ // counters as they stand on the row. `created_by` is likewise NOT
1001
+ // backfilled from `EXCLUDED`: the conflict path always means a prior
1002
+ // row already existed (including the race where a concurrent creator's
1003
+ // INSERT landed between our lock-nothing SELECT and this statement), so
1004
+ // `resources.created_by` -- the prior row's own value, absent or not --
1005
+ // is authoritative and this write's `createdBy` is ignored entirely.
1006
+ return { version };
1007
+ });
1008
+ }
1009
+ /**
1010
+ * @param options {object}
1011
+ * @param options.spaceId {string}
1012
+ * @param options.collectionId {string}
1013
+ * @param options.resourceId {string}
1014
+ * @param [options.contentType] {string} advisory; ignored for lookup
1015
+ * @returns {Promise<ResourceResult>}
1016
+ */
1017
+ async getResource({ spaceId, collectionId, resourceId }) {
1018
+ const { rows } = await this._pool.query(`SELECT content_type, content, version, deleted FROM resources
1019
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1020
+ const row = rows[0];
1021
+ if (!row || row.deleted || row.content === null) {
1022
+ throw new ResourceNotFoundError({ requestName: 'Get Resource' });
1023
+ }
1024
+ return {
1025
+ resourceStream: Readable.from(row.content),
1026
+ storedResourceType: row.content_type,
1027
+ version: row.version
1028
+ };
1029
+ }
1030
+ /**
1031
+ * Soft-deletes a Resource into a tombstone row: content dropped, `deleted`
1032
+ * set, `version` bumped (so the change feed surfaces it), last-known
1033
+ * `content_type` retained, `custom` / `meta_version` dropped, and the freed
1034
+ * bytes subtracted from the quota counter -- one transaction. Idempotent on
1035
+ * an absent Resource or an existing tombstone.
1036
+ * @param options {object}
1037
+ * @param options.spaceId {string}
1038
+ * @param options.collectionId {string}
1039
+ * @param options.resourceId {string}
1040
+ * @param [options.ifMatch] {string}
1041
+ * @returns {Promise<void>}
1042
+ */
1043
+ async deleteResource({ spaceId, collectionId, resourceId, ifMatch }) {
1044
+ await this._withTransaction(async (client) => {
1045
+ // Narrow projection: the lock needs the row, not the `content` bytea
1046
+ // that is about to be dropped anyway.
1047
+ const { rows } = await client.query(`SELECT version, size_bytes, deleted FROM resources
1048
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1049
+ FOR UPDATE`, [spaceId, collectionId, resourceId]);
1050
+ const prior = rows[0];
1051
+ const exists = prior !== undefined && !prior.deleted;
1052
+ if (ifMatch !== undefined) {
1053
+ assertWritePrecondition({
1054
+ resourceId,
1055
+ exists,
1056
+ currentVersion: prior?.version ?? 0,
1057
+ ifMatch
1058
+ });
1059
+ }
1060
+ if (!exists) {
1061
+ // Already absent (never existed, or already a tombstone): idempotent
1062
+ // no-op, keeping an existing tombstone's change-feed entry stable.
1063
+ return;
1064
+ }
1065
+ const freedBytes = Number(prior.size_bytes);
1066
+ if (freedBytes > 0) {
1067
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
1068
+ }
1069
+ const now = new Date().toISOString();
1070
+ await client.query(`UPDATE resources SET
1071
+ content = NULL,
1072
+ size_bytes = 0,
1073
+ version = version + 1,
1074
+ meta_version = NULL,
1075
+ custom = NULL,
1076
+ epoch = NULL,
1077
+ deleted = true,
1078
+ updated_at = $4
1079
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId, now]);
1080
+ });
1081
+ }
1082
+ /**
1083
+ * Reads the metadata of a Resource's current representation. Tombstones and
1084
+ * absent Resources resolve `undefined`. `custom` is included only when
1085
+ * non-empty, verbatim (`{ name, tags }` or the opaque envelope).
1086
+ * @param options {object}
1087
+ * @param options.spaceId {string}
1088
+ * @param options.collectionId {string}
1089
+ * @param options.resourceId {string}
1090
+ * @returns {Promise<(ResourceMetadata & { createdBy?: IDID; version?:
1091
+ * number; metaVersion?: number }) | undefined>}
1092
+ */
1093
+ async getResourceMetadata({ spaceId, collectionId, resourceId }) {
1094
+ const { rows } = await this._pool.query(`SELECT content_type, size_bytes, version, meta_version, custom, epoch,
1095
+ deleted, created_at, updated_at, created_by
1096
+ FROM resources
1097
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1098
+ const row = rows[0];
1099
+ if (!row || row.deleted) {
1100
+ return undefined;
1101
+ }
1102
+ const hasCustom = row.custom !== null && Object.keys(row.custom).length > 0;
1103
+ return {
1104
+ contentType: row.content_type,
1105
+ size: Number(row.size_bytes),
1106
+ createdAt: row.created_at,
1107
+ updatedAt: row.updated_at,
1108
+ // Absent for a Resource created before `createdBy` was recorded.
1109
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1110
+ ...(hasCustom && { custom: row.custom }),
1111
+ // The client-declared key epoch (the `key-epochs` feature), when stamped.
1112
+ ...(row.epoch !== null && { epoch: row.epoch }),
1113
+ version: row.version,
1114
+ ...(row.meta_version !== null && { metaVersion: row.meta_version })
1115
+ };
1116
+ }
1117
+ /**
1118
+ * Replaces the user-writable `custom` object (full replacement; `{}`
1119
+ * clears), bumping `updatedAt` and the independent `metaVersion` -- one
1120
+ * row-locked transaction, preconditions evaluated on the current
1121
+ * `metaVersion` via the shared helper. Resolves `undefined` (no create) for
1122
+ * an absent or tombstoned Resource.
1123
+ * @param options {object}
1124
+ * @param options.spaceId {string}
1125
+ * @param options.collectionId {string}
1126
+ * @param options.resourceId {string}
1127
+ * @param options.custom {ResourceMetadataCustom | Record<string, unknown>}
1128
+ * @param [options.ifMatch] {string}
1129
+ * @param [options.ifNoneMatch] {boolean}
1130
+ * @returns {Promise<{ metaVersion: number } | undefined>}
1131
+ */
1132
+ async writeResourceMetadata({ spaceId, collectionId, resourceId, custom, epoch, ifMatch, ifNoneMatch }) {
1133
+ return this._withTransaction(async (client) => {
1134
+ const { rows } = await client.query(`SELECT meta_version, deleted FROM resources
1135
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1136
+ FOR UPDATE`, [spaceId, collectionId, resourceId]);
1137
+ const prior = rows[0];
1138
+ if (!prior || prior.deleted) {
1139
+ return undefined;
1140
+ }
1141
+ assertMetaWritePrecondition({
1142
+ resourceId,
1143
+ metaVersion: prior.meta_version ?? undefined,
1144
+ ifMatch,
1145
+ ifNoneMatch
1146
+ });
1147
+ const metaVersion = (prior.meta_version ?? 0) + 1;
1148
+ const hasCustom = Object.keys(custom).length > 0;
1149
+ const now = new Date().toISOString();
1150
+ // The key-epoch stamp describes the CONTENT write, so a supplied `epoch`
1151
+ // replaces it but an OMITTED one PRESERVES the stored value (unlike
1152
+ // `custom`, full-replace): `COALESCE($7, epoch)` keeps the current value
1153
+ // when the parameter is NULL.
1154
+ await client.query(`UPDATE resources SET
1155
+ meta_version = $4,
1156
+ custom = $5::jsonb,
1157
+ updated_at = $6,
1158
+ epoch = COALESCE($7, epoch)
1159
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [
1160
+ spaceId,
1161
+ collectionId,
1162
+ resourceId,
1163
+ metaVersion,
1164
+ hasCustom ? JSON.stringify(custom) : null,
1165
+ now,
1166
+ epoch ?? null
1167
+ ]);
1168
+ return { metaVersion };
1169
+ });
1170
+ }
1171
+ /**
1172
+ * Replication change feed (the `changes` query profile): one indexed keyset
1173
+ * query over `(updatedAt, resourceId)`, tombstones included, JSON documents
1174
+ * only, bodies parsed for the returned page.
1175
+ * @param options {object}
1176
+ * @param options.spaceId {string}
1177
+ * @param options.collectionId {string}
1178
+ * @param [options.checkpoint] {{ id: string, updatedAt: string }}
1179
+ * @param options.limit {number}
1180
+ * @returns {Promise<{ documents: Array<object>, checkpoint: object | null }>}
1181
+ */
1182
+ async changesSince({ spaceId, collectionId, checkpoint, limit }) {
1183
+ const pageSize = clampPageSize(limit);
1184
+ const { rows } = await this._pool.query(`SELECT resource_id, content, version, meta_version, custom, epoch,
1185
+ deleted, updated_at, created_by
1186
+ FROM resources
1187
+ WHERE space_id = $1 AND collection_id = $2 AND is_json
1188
+ AND ($3::text IS NULL OR (updated_at, resource_id) > ($3, $4))
1189
+ ORDER BY updated_at, resource_id
1190
+ LIMIT $5`, [
1191
+ spaceId,
1192
+ collectionId,
1193
+ checkpoint?.updatedAt ?? null,
1194
+ checkpoint?.id ?? null,
1195
+ pageSize
1196
+ ]);
1197
+ const documents = rows.map(row => {
1198
+ if (row.deleted) {
1199
+ return {
1200
+ resourceId: row.resource_id,
1201
+ version: row.version,
1202
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
1203
+ // A tombstone keeps its creator, as it keeps its `created_at`.
1204
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1205
+ updatedAt: row.updated_at,
1206
+ deleted: true
1207
+ };
1208
+ }
1209
+ let data;
1210
+ try {
1211
+ data = row.content
1212
+ ? JSON.parse(row.content.toString('utf8'))
1213
+ : undefined;
1214
+ }
1215
+ catch {
1216
+ data = undefined;
1217
+ }
1218
+ return {
1219
+ resourceId: row.resource_id,
1220
+ version: row.version,
1221
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
1222
+ // The creator's DID rides the feed so provenance replicates with the
1223
+ // document, rather than needing a `/meta` fetch per Resource.
1224
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1225
+ updatedAt: row.updated_at,
1226
+ deleted: false,
1227
+ data,
1228
+ ...(row.custom !== null && { custom: row.custom }),
1229
+ // The client-declared key epoch (the `key-epochs` feature) rides the
1230
+ // feed so a replicating reader picks the right epoch key.
1231
+ ...(row.epoch !== null && { epoch: row.epoch })
1232
+ };
1233
+ });
1234
+ const last = documents[documents.length - 1];
1235
+ return {
1236
+ documents,
1237
+ checkpoint: last
1238
+ ? { id: last.resourceId, updatedAt: last.updatedAt }
1239
+ : null
1240
+ };
1241
+ }
1242
+ /**
1243
+ * Blinded-index query (the `blinded-index` query profile; see the
1244
+ * `StorageBackend.queryByBlindedIndex` contract). Selects the Collection's
1245
+ * live JSON rows, parses each body, and hands the candidates to the shared
1246
+ * evaluator (`lib/blindedIndex.ts`) for matching, ordering, and cursor
1247
+ * pagination -- identical semantics to the filesystem backend. A full scan
1248
+ * of the Collection per call, deliberate for this teaching backend; an
1249
+ * indexed variant would flatten the blinded attributes into an indexed
1250
+ * token side-table (the bedrock-edv-storage strategy). Unparsable JSON is
1251
+ * skipped.
1252
+ * @param options {object}
1253
+ * @param options.spaceId {string}
1254
+ * @param options.collectionId {string}
1255
+ * @param options.query {BlindedIndexQuery}
1256
+ * @param [options.count] {boolean} return only the match count
1257
+ * @param [options.limit] {number} requested page size
1258
+ * @param [options.cursor] {string} opaque cursor from a prior page
1259
+ * @returns {Promise<{ count: number } | BlindedIndexQueryPage>}
1260
+ */
1261
+ async queryByBlindedIndex({ spaceId, collectionId, query, count, limit, cursor }) {
1262
+ const { rows } = await this._pool.query(`SELECT resource_id, content FROM resources
1263
+ WHERE space_id = $1 AND collection_id = $2 AND is_json AND NOT deleted
1264
+ ORDER BY resource_id`, [spaceId, collectionId]);
1265
+ const candidates = [];
1266
+ for (const row of rows) {
1267
+ if (!row.content) {
1268
+ continue;
1269
+ }
1270
+ try {
1271
+ candidates.push({
1272
+ resourceId: row.resource_id,
1273
+ document: JSON.parse(row.content.toString('utf8'))
1274
+ });
1275
+ }
1276
+ catch {
1277
+ // skip an unparsable body
1278
+ }
1279
+ }
1280
+ return runBlindedIndexQuery({ candidates, query, count, limit, cursor });
1281
+ }
1282
+ // Policies
1283
+ /**
1284
+ * Maps the optional-id policy addressing onto the sentinel-column primary
1285
+ * key: Space policy `('', '')`, Collection policy `(cid, '')`, Resource
1286
+ * policy `(cid, rid)`.
1287
+ * @param options {object}
1288
+ * @param [options.collectionId] {string}
1289
+ * @param [options.resourceId] {string}
1290
+ * @returns {{ collectionKey: string, resourceKey: string }}
1291
+ */
1292
+ _policyKey({ collectionId, resourceId }) {
1293
+ return {
1294
+ collectionKey: collectionId ?? '',
1295
+ resourceKey: resourceId ?? ''
1296
+ };
1297
+ }
1298
+ /**
1299
+ * @param options {object}
1300
+ * @param options.spaceId {string}
1301
+ * @param [options.collectionId] {string}
1302
+ * @param [options.resourceId] {string}
1303
+ * @returns {Promise<PolicyDocument|undefined>}
1304
+ */
1305
+ async getPolicy({ spaceId, collectionId, resourceId }) {
1306
+ const { collectionKey, resourceKey } = this._policyKey({
1307
+ collectionId,
1308
+ resourceId
1309
+ });
1310
+ const { rows } = await this._pool.query(`SELECT policy FROM policies
1311
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionKey, resourceKey]);
1312
+ return rows[0]?.policy;
1313
+ }
1314
+ /**
1315
+ * @param options {object}
1316
+ * @param options.spaceId {string}
1317
+ * @param [options.collectionId] {string}
1318
+ * @param [options.resourceId] {string}
1319
+ * @param options.policy {PolicyDocument}
1320
+ * @returns {Promise<void>}
1321
+ */
1322
+ async writePolicy({ spaceId, collectionId, resourceId, policy }) {
1323
+ await this._withTransaction(async (client) => {
1324
+ // Ensure the containing rows exist (Space, and the Collection when the
1325
+ // policy is below Space level), like the filesystem's dir provisioning.
1326
+ if (collectionId !== undefined) {
1327
+ await this._ensureCollectionRow({ client, spaceId, collectionId });
1328
+ }
1329
+ else {
1330
+ await this._ensureSpaceRow({ client, spaceId });
1331
+ }
1332
+ await this._upsertPolicy({
1333
+ queryable: client,
1334
+ spaceId,
1335
+ collectionId,
1336
+ resourceId,
1337
+ policy
1338
+ });
1339
+ });
1340
+ }
1341
+ /**
1342
+ * The one policy upsert statement, shared by `writePolicy` and the import
1343
+ * apply loop; keys through `_policyKey` so the sentinel convention lives in
1344
+ * one place.
1345
+ * @param options {object}
1346
+ * @param options.queryable {Queryable}
1347
+ * @param options.spaceId {string}
1348
+ * @param [options.collectionId] {string}
1349
+ * @param [options.resourceId] {string}
1350
+ * @param options.policy {PolicyDocument}
1351
+ * @returns {Promise<void>}
1352
+ */
1353
+ async _upsertPolicy({ queryable, spaceId, collectionId, resourceId, policy }) {
1354
+ const { collectionKey, resourceKey } = this._policyKey({
1355
+ collectionId,
1356
+ resourceId
1357
+ });
1358
+ await queryable.query(`INSERT INTO policies (space_id, collection_id, resource_id, policy)
1359
+ VALUES ($1, $2, $3, $4::jsonb)
1360
+ ON CONFLICT (space_id, collection_id, resource_id)
1361
+ DO UPDATE SET policy = EXCLUDED.policy`, [spaceId, collectionKey, resourceKey, JSON.stringify(policy)]);
1362
+ }
1363
+ /**
1364
+ * @param options {object}
1365
+ * @param options.spaceId {string}
1366
+ * @param [options.collectionId] {string}
1367
+ * @param [options.resourceId] {string}
1368
+ * @returns {Promise<void>} idempotent
1369
+ */
1370
+ async deletePolicy({ spaceId, collectionId, resourceId }) {
1371
+ const { collectionKey, resourceKey } = this._policyKey({
1372
+ collectionId,
1373
+ resourceId
1374
+ });
1375
+ await this._pool.query(`DELETE FROM policies
1376
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionKey, resourceKey]);
1377
+ }
1378
+ // Registered external backends (spec "Backends")
1379
+ /**
1380
+ * Persists a full (secret-bearing) backend-registration record. Upsert.
1381
+ * @param options {object}
1382
+ * @param options.spaceId {string}
1383
+ * @param options.backendId {string}
1384
+ * @param options.record {StoredBackendRecord}
1385
+ * @returns {Promise<void>}
1386
+ */
1387
+ async writeBackend({ spaceId, backendId, record }) {
1388
+ await this._withTransaction(async (client) => {
1389
+ await this._ensureSpaceRow({ client, spaceId });
1390
+ await client.query(`INSERT INTO backend_records (space_id, backend_id, record)
1391
+ VALUES ($1, $2, $3::jsonb)
1392
+ ON CONFLICT (space_id, backend_id)
1393
+ DO UPDATE SET record = EXCLUDED.record`, [spaceId, backendId, JSON.stringify(record)]);
1394
+ });
1395
+ }
1396
+ /**
1397
+ * The full (secret-bearing) record, or `undefined`. The only method that
1398
+ * exposes secret connection material -- internal use.
1399
+ * @param options {object}
1400
+ * @param options.spaceId {string}
1401
+ * @param options.backendId {string}
1402
+ * @returns {Promise<StoredBackendRecord|undefined>}
1403
+ */
1404
+ async getBackend({ spaceId, backendId }) {
1405
+ const { rows } = await this._pool.query(`SELECT record FROM backend_records
1406
+ WHERE space_id = $1 AND backend_id = $2`, [spaceId, backendId]);
1407
+ return rows[0]?.record;
1408
+ }
1409
+ /**
1410
+ * The Space's registered backends, **sanitized** (mapped through
1411
+ * `sanitizeBackendRecord` -- the secret boundary is unchanged), sorted by id.
1412
+ * @param options {object}
1413
+ * @param options.spaceId {string}
1414
+ * @returns {Promise<BackendDescriptor[]>}
1415
+ */
1416
+ async listBackends({ spaceId }) {
1417
+ const { rows } = await this._pool.query(`SELECT record FROM backend_records
1418
+ WHERE space_id = $1 ORDER BY backend_id`, [spaceId]);
1419
+ return rows.map(row => sanitizeBackendRecord(row.record));
1420
+ }
1421
+ /**
1422
+ * @param options {object}
1423
+ * @param options.spaceId {string}
1424
+ * @param options.backendId {string}
1425
+ * @returns {Promise<void>} idempotent
1426
+ */
1427
+ async deleteBackend({ spaceId, backendId }) {
1428
+ await this._pool.query(`DELETE FROM backend_records WHERE space_id = $1 AND backend_id = $2`, [spaceId, backendId]);
1429
+ }
1430
+ // WebKMS keystores (the `/kms` facet)
1431
+ /**
1432
+ * Persists a keystore config unconditionally (the create path; local ids
1433
+ * are server-generated random values). The queryable/gated fields
1434
+ * (`controller`, `sequence`, `kmsModule`) are denormalized alongside the
1435
+ * verbatim config.
1436
+ * @param options {object}
1437
+ * @param options.keystoreId {string}
1438
+ * @param options.config {KeystoreConfig}
1439
+ * @returns {Promise<void>}
1440
+ */
1441
+ async writeKeystore({ keystoreId, config }) {
1442
+ await this._pool.query(`INSERT INTO keystores (keystore_id, controller, sequence, kms_module, config)
1443
+ VALUES ($1, $2, $3, $4, $5::jsonb)
1444
+ ON CONFLICT (keystore_id) DO UPDATE SET
1445
+ controller = EXCLUDED.controller,
1446
+ sequence = EXCLUDED.sequence,
1447
+ kms_module = EXCLUDED.kms_module,
1448
+ config = EXCLUDED.config`, [
1449
+ keystoreId,
1450
+ config.controller,
1451
+ config.sequence,
1452
+ config.kmsModule,
1453
+ JSON.stringify(config)
1454
+ ]);
1455
+ }
1456
+ /**
1457
+ * @param options {object}
1458
+ * @param options.keystoreId {string}
1459
+ * @returns {Promise<KeystoreConfig|undefined>}
1460
+ */
1461
+ async getKeystore({ keystoreId }) {
1462
+ const { rows } = await this._pool.query('SELECT config FROM keystores WHERE keystore_id = $1', [keystoreId]);
1463
+ return rows[0]?.config;
1464
+ }
1465
+ /**
1466
+ * Replaces a keystore config, gated atomically in one conditional `UPDATE`:
1467
+ * the row must exist with `sequence` exactly one less than the incoming
1468
+ * config's and an unchanged `kmsModule`. Zero rows updated -- missing
1469
+ * keystore, stale sequence, or module change alike -- rejects with the
1470
+ * protocol's single merged 409 (`KeystoreStateConflictError`).
1471
+ * @param options {object}
1472
+ * @param options.keystoreId {string}
1473
+ * @param options.config {KeystoreConfig}
1474
+ * @returns {Promise<void>}
1475
+ */
1476
+ async updateKeystore({ keystoreId, config }) {
1477
+ const result = await this._pool.query(`UPDATE keystores SET
1478
+ controller = $2,
1479
+ sequence = $3,
1480
+ config = $4::jsonb
1481
+ WHERE keystore_id = $1 AND sequence = $3 - 1 AND kms_module = $5`, [
1482
+ keystoreId,
1483
+ config.controller,
1484
+ config.sequence,
1485
+ JSON.stringify(config),
1486
+ config.kmsModule
1487
+ ]);
1488
+ if (result.rowCount === 0) {
1489
+ throw new KeystoreStateConflictError();
1490
+ }
1491
+ }
1492
+ /**
1493
+ * Every stored keystore config whose `controller` matches, sorted by local
1494
+ * id (the request layer caps the wire result).
1495
+ * @param options {object}
1496
+ * @param options.controller {IDID}
1497
+ * @returns {Promise<KeystoreConfig[]>}
1498
+ */
1499
+ async listKeystoresByController({ controller }) {
1500
+ const { rows } = await this._pool.query(`SELECT config FROM keystores
1501
+ WHERE controller = $1 ORDER BY keystore_id`, [controller]);
1502
+ return rows.map(row => row.config);
1503
+ }
1504
+ /**
1505
+ * Inserts a key record, create-only: the primary key enforces the
1506
+ * `(keystoreId, localId)` uniqueness atomically; a duplicate rejects with
1507
+ * the protocol's 409 (`KeyIdConflictError`). The record is stored verbatim
1508
+ * (opaque to storage -- the at-rest cipher applies above the backend).
1509
+ * @param options {object}
1510
+ * @param options.keystoreId {string}
1511
+ * @param options.localId {string}
1512
+ * @param options.record {KmsKeyRecord}
1513
+ * @returns {Promise<void>}
1514
+ */
1515
+ async insertKey({ keystoreId, localId, record }) {
1516
+ try {
1517
+ await this._pool.query(`INSERT INTO kms_keys (keystore_id, local_id, record)
1518
+ VALUES ($1, $2, $3::jsonb)`, [keystoreId, localId, JSON.stringify(record)]);
1519
+ }
1520
+ catch (err) {
1521
+ if (err.code === '23505') {
1522
+ throw new KeyIdConflictError();
1523
+ }
1524
+ throw new StorageError({ cause: err });
1525
+ }
1526
+ }
1527
+ /**
1528
+ * @param options {object}
1529
+ * @param options.keystoreId {string}
1530
+ * @param options.localId {string}
1531
+ * @returns {Promise<KmsKeyRecord|undefined>}
1532
+ */
1533
+ async getKey({ keystoreId, localId }) {
1534
+ const { rows } = await this._pool.query(`SELECT record FROM kms_keys
1535
+ WHERE keystore_id = $1 AND local_id = $2`, [keystoreId, localId]);
1536
+ return rows[0]?.record;
1537
+ }
1538
+ /**
1539
+ * Every stored key record under the keystore, sorted by local id (the request
1540
+ * layer caps and paginates the wire result). An empty keystore resolves an
1541
+ * empty list. The record is returned verbatim -- the at-rest cipher applies
1542
+ * above the backend.
1543
+ * @param options {object}
1544
+ * @param options.keystoreId {string}
1545
+ * @returns {Promise<Array<{ localId: string, record: KmsKeyRecord }>>}
1546
+ */
1547
+ async listKeys({ keystoreId }) {
1548
+ const { rows } = await this._pool.query(`SELECT local_id, record FROM kms_keys
1549
+ WHERE keystore_id = $1 ORDER BY local_id`, [keystoreId]);
1550
+ return rows.map(row => ({ localId: row.local_id, record: row.record }));
1551
+ }
1552
+ /**
1553
+ * Resolves a revocation scope to the table it lives in, the scope column
1554
+ * within that table, and the scope id value. The returned `table` and
1555
+ * `column` are server-chosen constants (never user input), so callers may
1556
+ * safely interpolate them into a SQL template; `id` remains a bound value.
1557
+ * @param scope {RevocationScope}
1558
+ * @returns {{ table: string, column: string, id: string }}
1559
+ */
1560
+ _revocationTable(scope) {
1561
+ if ('keystoreId' in scope) {
1562
+ return {
1563
+ table: 'revocations',
1564
+ column: 'keystore_id',
1565
+ id: scope.keystoreId
1566
+ };
1567
+ }
1568
+ return { table: 'space_revocations', column: 'space_id', id: scope.spaceId };
1569
+ }
1570
+ /**
1571
+ * Inserts a revocation record, create-only on
1572
+ * `(scope id, delegator, capability.id)`; a duplicate rejects with the
1573
+ * protocol's 409 (`DuplicateRevocationError`).
1574
+ * @param options {object}
1575
+ * @param options.scope {RevocationScope}
1576
+ * @param options.record {RevocationRecord}
1577
+ * @returns {Promise<void>}
1578
+ */
1579
+ async insertRevocation({ scope, record }) {
1580
+ // `table` / `column` are internal constants, not user input; ids are bound.
1581
+ const { table, column, id } = this._revocationTable(scope);
1582
+ try {
1583
+ // Prune rows past their GC horizon while on this (rare) write path, so
1584
+ // the hot read path (`isRevoked`, consulted on every delegated-chain
1585
+ // verification) stays a single read-only SELECT -- the SQL analogue of
1586
+ // a TTL index. Table-wide on purpose: expired rows are dead weight
1587
+ // whichever scope they belong to.
1588
+ await this._pool.query(`DELETE FROM ${table}
1589
+ WHERE expires IS NOT NULL AND expires <= $1`, [new Date().toISOString()]);
1590
+ await this._pool.query(`INSERT INTO ${table}
1591
+ (${column}, delegator, capability_id, record, expires)
1592
+ VALUES ($1, $2, $3, $4::jsonb, $5)`, [
1593
+ id,
1594
+ record.meta.delegator,
1595
+ record.capability.id,
1596
+ JSON.stringify(record),
1597
+ record.meta.expires ?? null
1598
+ ]);
1599
+ }
1600
+ catch (err) {
1601
+ if (err.code === '23505') {
1602
+ throw new DuplicateRevocationError();
1603
+ }
1604
+ throw new StorageError({ cause: err });
1605
+ }
1606
+ }
1607
+ /**
1608
+ * True when any of the given capabilities has a stored, unexpired
1609
+ * revocation under the scope. A single read-only SELECT: rows past their
1610
+ * `meta.expires` GC horizon are filtered out in the predicate rather than
1611
+ * pruned here -- this runs on every delegated-chain verification, so it
1612
+ * must not write; `insertRevocation` prunes on the (rare) write path
1613
+ * instead. ISO-8601 strings compare correctly under the column's byte-order
1614
+ * collation.
1615
+ * @param options {object}
1616
+ * @param options.scope {RevocationScope}
1617
+ * @param options.capabilities {CapabilitySummary[]}
1618
+ * @returns {Promise<boolean>}
1619
+ */
1620
+ async isRevoked({ scope, capabilities }) {
1621
+ if (capabilities.length === 0) {
1622
+ return false;
1623
+ }
1624
+ // `table` / `column` are internal constants, not user input; ids are bound.
1625
+ const { table, column, id } = this._revocationTable(scope);
1626
+ const delegators = capabilities.map(entry => entry.delegator);
1627
+ const capabilityIds = capabilities.map(entry => entry.capabilityId);
1628
+ const { rows } = await this._pool.query(`SELECT 1 FROM ${table}
1629
+ WHERE ${column} = $1
1630
+ AND (delegator, capability_id) IN
1631
+ (SELECT * FROM unnest($2::text[], $3::text[]))
1632
+ AND (expires IS NULL OR expires > $4)
1633
+ LIMIT 1`, [id, delegators, capabilityIds, new Date().toISOString()]);
1634
+ return rows.length > 0;
1635
+ }
1636
+ // Export / import (spec "Export Space" / "Import Space")
1637
+ /**
1638
+ * Builds the filesystem-dialect sidecar object for a resource row (the
1639
+ * `.meta.<id>.json` shape), field order matching the filesystem writer so
1640
+ * archives stay as close to byte-compatible as jsonb round-tripping allows.
1641
+ * @param row {Omit<ResourceRow, 'content'>}
1642
+ * @returns {SidecarShape}
1643
+ */
1644
+ _sidecarFor(row) {
1645
+ if (row.deleted) {
1646
+ return {
1647
+ createdAt: row.created_at,
1648
+ updatedAt: row.updated_at,
1649
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1650
+ version: row.version,
1651
+ deleted: true,
1652
+ contentType: row.content_type
1653
+ };
1654
+ }
1655
+ return {
1656
+ createdAt: row.created_at,
1657
+ updatedAt: row.updated_at,
1658
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1659
+ version: row.version,
1660
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
1661
+ ...(row.custom !== null && { custom: row.custom }),
1662
+ // The client-declared key epoch (the `key-epochs` feature) rides the
1663
+ // `.meta.` sidecar so it survives an export/import round trip.
1664
+ ...(row.epoch !== null && { epoch: row.epoch })
1665
+ };
1666
+ }
1667
+ /**
1668
+ * Exports the Space as a tar stream in the exact filesystem on-disk layout
1669
+ * (same file-name codecs, same manifest), so the archive imports into
1670
+ * either backend. Backend registration records are excluded (secret
1671
+ * material), exactly as on the filesystem.
1672
+ * @param options {object}
1673
+ * @param options.spaceId {string}
1674
+ * @returns {Promise<Readable>} tar-stream pack
1675
+ */
1676
+ async exportSpace({ spaceId }) {
1677
+ const spaceDescription = await this.getSpaceDescription({ spaceId });
1678
+ if (!spaceDescription) {
1679
+ throw new SpaceNotFoundError({ requestName: 'Export Space' });
1680
+ }
1681
+ const [{ rows: policyRows }, { rows: collectionRows }, { rows: resourceRows }, { rows: revocationRows }] = await Promise.all([
1682
+ this._pool.query(`SELECT collection_id, resource_id, policy FROM policies
1683
+ WHERE space_id = $1`, [spaceId]),
1684
+ this._pool.query(`SELECT collection_id, description, description_version FROM collections
1685
+ WHERE space_id = $1`, [spaceId]),
1686
+ // Metadata only -- content bytes are fetched one resource at a time
1687
+ // while packing, so an export never holds the whole Space in memory.
1688
+ this._pool.query(`SELECT collection_id, resource_id, content_type, is_json,
1689
+ size_bytes, version, meta_version, custom, epoch, deleted,
1690
+ created_at, updated_at, created_by
1691
+ FROM resources WHERE space_id = $1`, [spaceId]),
1692
+ this._pool.query(`SELECT delegator, capability_id, record FROM space_revocations
1693
+ WHERE space_id = $1`, [spaceId])
1694
+ ]);
1695
+ // Assemble the per-entry file lists in the filesystem's shapes: files are
1696
+ // named by the shared codecs and sorted with localeCompare, matching the
1697
+ // filesystem's directory-listing sort.
1698
+ const spacePolicy = policyRows.find(row => row.collection_id === '' && row.resource_id === '')?.policy;
1699
+ // Space-level dot-files are always small JSON, carried inline.
1700
+ const spaceFiles = [
1701
+ {
1702
+ name: `.space.${spaceId}.json`,
1703
+ bytes: Buffer.from(JSON.stringify(spaceDescription))
1704
+ }
1705
+ ];
1706
+ if (spacePolicy) {
1707
+ spaceFiles.push({
1708
+ name: `.policy.${spaceId}.json`,
1709
+ bytes: Buffer.from(JSON.stringify(spacePolicy))
1710
+ });
1711
+ }
1712
+ const collectionsById = new Map();
1713
+ const filesFor = (collectionId) => {
1714
+ let files = collectionsById.get(collectionId);
1715
+ if (!files) {
1716
+ files = [];
1717
+ collectionsById.set(collectionId, files);
1718
+ }
1719
+ return files;
1720
+ };
1721
+ for (const row of collectionRows) {
1722
+ const files = filesFor(row.collection_id);
1723
+ if (row.description !== null) {
1724
+ // Embed the description version as `_version` in the archived
1725
+ // `.collection.` file (the filesystem backend's on-disk convention) so
1726
+ // the ETag validator survives an export/import round trip and archives
1727
+ // stay interchangeable between the two backends.
1728
+ files.push({
1729
+ name: `.collection.${row.collection_id}.json`,
1730
+ bytes: Buffer.from(JSON.stringify({
1731
+ ...row.description,
1732
+ _version: row.description_version
1733
+ }))
1734
+ });
1735
+ }
1736
+ }
1737
+ for (const row of policyRows) {
1738
+ if (row.collection_id === '') {
1739
+ continue;
1740
+ }
1741
+ // Collection policy keys by the collection id, resource policy by the
1742
+ // resource id -- same dot-file convention, distinct keying ids.
1743
+ const keyId = row.resource_id === '' ? row.collection_id : row.resource_id;
1744
+ filesFor(row.collection_id).push({
1745
+ name: `.policy.${keyId}.json`,
1746
+ bytes: Buffer.from(JSON.stringify(row.policy))
1747
+ });
1748
+ }
1749
+ for (const row of resourceRows) {
1750
+ const files = filesFor(row.collection_id);
1751
+ files.push({
1752
+ name: `.meta.${row.resource_id}.json`,
1753
+ bytes: Buffer.from(JSON.stringify(this._sidecarFor(row)))
1754
+ });
1755
+ if (!row.deleted) {
1756
+ files.push({
1757
+ name: fileNameFor({
1758
+ resourceId: row.resource_id,
1759
+ contentType: row.content_type
1760
+ }),
1761
+ resource: {
1762
+ collectionId: row.collection_id,
1763
+ resourceId: row.resource_id
1764
+ }
1765
+ });
1766
+ }
1767
+ }
1768
+ // Top-level order: space-level files and collection dirs interleaved,
1769
+ // sorted by name -- the same order the filesystem's readdir+sort yields.
1770
+ const topLevel = [
1771
+ ...spaceFiles.map(file => ({ kind: 'file', ...file })),
1772
+ ...[...collectionsById].map(([collectionId, files]) => ({
1773
+ kind: 'collection',
1774
+ name: collectionId,
1775
+ files: files.sort((a, b) => a.name.localeCompare(b.name))
1776
+ }))
1777
+ ].sort((a, b) => a.name.localeCompare(b.name));
1778
+ // Space-scoped zcap revocations travel with the export, packed under a
1779
+ // top-level `revocations/` dir and named by the shared file-name codec so
1780
+ // both backends produce the same archive entries. Pretty-printed to match
1781
+ // the filesystem's stored records.
1782
+ const revocationFiles = revocationRows
1783
+ .map(row => ({
1784
+ name: revocationFileName({
1785
+ delegator: row.delegator,
1786
+ capabilityId: row.capability_id
1787
+ }),
1788
+ bytes: Buffer.from(JSON.stringify(row.record, null, 2))
1789
+ }))
1790
+ .sort((a, b) => a.name.localeCompare(b.name));
1791
+ const manifest = buildExportManifest({
1792
+ spaceId,
1793
+ entries: topLevel.map(entry => entry.kind === 'collection'
1794
+ ? { name: entry.name, files: entry.files.map(file => file.name) }
1795
+ : { name: entry.name }),
1796
+ revocationFiles: revocationFiles.map(file => file.name)
1797
+ });
1798
+ const pack = tar.pack();
1799
+ pack.entry({ name: 'manifest.yml' }, YAML.stringify(manifest));
1800
+ pack.entry({ name: 'space/', type: 'directory' });
1801
+ pack.entry({ name: `space/${spaceId}/`, type: 'directory' });
1802
+ for (const entry of topLevel) {
1803
+ const entryTarget = `space/${spaceId}/${entry.name}`;
1804
+ if (entry.kind === 'collection') {
1805
+ pack.entry({ name: `${entryTarget}/`, type: 'directory' });
1806
+ for (const file of entry.files) {
1807
+ const bytes = 'bytes' in file
1808
+ ? file.bytes
1809
+ : await this._resourceContent({ spaceId, ...file.resource });
1810
+ pack.entry({ name: `${entryTarget}/${file.name}` }, bytes);
1811
+ }
1812
+ }
1813
+ else {
1814
+ pack.entry({ name: entryTarget }, entry.bytes);
1815
+ }
1816
+ }
1817
+ if (revocationFiles.length > 0) {
1818
+ pack.entry({ name: 'revocations/', type: 'directory' });
1819
+ for (const file of revocationFiles) {
1820
+ pack.entry({ name: `revocations/${file.name}` }, file.bytes);
1821
+ }
1822
+ }
1823
+ pack.finalize();
1824
+ return pack;
1825
+ }
1826
+ /**
1827
+ * Fetches one resource's content bytes for the export pack loop. A row
1828
+ * deleted or tombstoned between the metadata pass and this read (the export
1829
+ * is not one transaction, same as the filesystem's racy directory walk)
1830
+ * yields an empty body rather than failing the whole archive.
1831
+ * @param options {object}
1832
+ * @param options.spaceId {string}
1833
+ * @param options.collectionId {string}
1834
+ * @param options.resourceId {string}
1835
+ * @returns {Promise<Buffer>}
1836
+ */
1837
+ async _resourceContent({ spaceId, collectionId, resourceId }) {
1838
+ const { rows } = await this._pool.query(`SELECT content FROM resources
1839
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
1840
+ return rows[0]?.content ?? Buffer.alloc(0);
1841
+ }
1842
+ /**
1843
+ * Merges a WAS space-export tarball into an existing Space with the same
1844
+ * three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
1845
+ * conformance, cumulative 507) and skip-not-overwrite merge semantics as
1846
+ * the filesystem backend -- including tombstone carry-over and "a tombstone
1847
+ * blocks resurrection". One strict improvement: the entire apply loop runs
1848
+ * in a single transaction, so a mid-import failure leaves the Space
1849
+ * untouched atomically.
1850
+ * @param options {object}
1851
+ * @param options.spaceId {string}
1852
+ * @param options.tarStream {Readable}
1853
+ * @returns {Promise<ImportStats>}
1854
+ */
1855
+ async importSpace({ spaceId, tarStream }) {
1856
+ const entries = await extractTarEntries(tarStream);
1857
+ const { spacePolicy, collections, revocations } = buildImportPlan(entries);
1858
+ const { capacityBytes, maxUploadBytes, maxCollectionsPerSpace, maxResourcesPerSpace } = this;
1859
+ return this._withTransaction(async (client) => {
1860
+ await this._ensureSpaceRow({ client, spaceId });
1861
+ // Serialize with concurrent writers on this Space for the duration of
1862
+ // the import: the usage counter row is the natural lock.
1863
+ const { rows: spaceRows } = await client.query('SELECT usage_bytes FROM spaces WHERE space_id = $1 FOR UPDATE', [spaceId]);
1864
+ const currentUsage = Number(spaceRows[0]?.usage_bytes ?? 0);
1865
+ // One pass over the Space's Collections: description presence drives
1866
+ // both the pre-flight encryption resolution and the skip-or-create
1867
+ // decision in the apply loop (a NULL-description placeholder row counts
1868
+ // as "does not exist", like a description-less directory).
1869
+ const { rows: descriptionRows } = await client.query(`SELECT collection_id, description FROM collections
1870
+ WHERE space_id = $1`, [spaceId]);
1871
+ const descriptionsById = new Map(descriptionRows.map(row => [row.collection_id, row.description]));
1872
+ // Count quotas: measure the Space's existing Collection rows / live
1873
+ // Resources ONCE here, then track running totals as the apply loop
1874
+ // creates items, so an import cannot push the Space past
1875
+ // `maxCollectionsPerSpace` / `maxResourcesPerSpace`. Only brand-new items
1876
+ // count -- a re-imported existing id is skipped and does not -- mirroring
1877
+ // the per-create write-path guards without a COUNT query per row. The
1878
+ // transaction rolls the whole import back if a cap is exceeded mid-apply.
1879
+ let collectionRowCount = descriptionsById.size;
1880
+ let liveResourceCount = 0;
1881
+ if (maxResourcesPerSpace !== undefined) {
1882
+ const { rows: liveRows } = await client.query(`SELECT COUNT(*)::int AS count FROM resources
1883
+ WHERE space_id = $1 AND NOT deleted`, [spaceId]);
1884
+ liveResourceCount = liveRows[0].count;
1885
+ }
1886
+ // Pre-flight pass over every staged resource, before writing anything.
1887
+ // Skips (existing ids) are counted conservatively for the quota
1888
+ // estimate, as on the filesystem.
1889
+ let incomingBytes = 0;
1890
+ for (const { collectionId, collectionDescription, resources } of collections) {
1891
+ const existing = descriptionsById.get(collectionId) ?? undefined;
1892
+ const effectiveEncryption = existing
1893
+ ? existing.encryption
1894
+ : collectionDescription.encryption;
1895
+ for (const { fileName, body } of resources) {
1896
+ if (body.length > maxUploadBytes) {
1897
+ throw new PayloadTooLargeError({
1898
+ maxUploadBytes,
1899
+ backendId: this.describe().id,
1900
+ uploadBytes: body.length
1901
+ });
1902
+ }
1903
+ if (effectiveEncryption?.scheme !== undefined) {
1904
+ const { contentType } = parseResourceFileName(fileName);
1905
+ let parsedBody;
1906
+ try {
1907
+ parsedBody = JSON.parse(body.toString('utf8'));
1908
+ }
1909
+ catch {
1910
+ parsedBody = undefined;
1911
+ }
1912
+ assertEncryptedWriteConforms({
1913
+ collectionDescription: { encryption: effectiveEncryption },
1914
+ contentType,
1915
+ body: parsedBody
1916
+ });
1917
+ }
1918
+ incomingBytes += body.length;
1919
+ }
1920
+ }
1921
+ if (capacityBytes !== undefined &&
1922
+ currentUsage + incomingBytes > capacityBytes) {
1923
+ throw new QuotaExceededError({ spaceId, capacityBytes });
1924
+ }
1925
+ const stats = {
1926
+ collectionsCreated: 0,
1927
+ collectionsSkipped: 0,
1928
+ resourcesCreated: 0,
1929
+ resourcesSkipped: 0,
1930
+ policiesCreated: 0,
1931
+ policiesSkipped: 0
1932
+ };
1933
+ // Space-level policy: restore it when the destination has none.
1934
+ if (spacePolicy) {
1935
+ const { rows } = await client.query(`SELECT 1 FROM policies
1936
+ WHERE space_id = $1 AND collection_id = '' AND resource_id = ''`, [spaceId]);
1937
+ if (rows.length > 0) {
1938
+ stats.policiesSkipped++;
1939
+ }
1940
+ else {
1941
+ await this._upsertPolicy({
1942
+ queryable: client,
1943
+ spaceId,
1944
+ policy: spacePolicy
1945
+ });
1946
+ stats.policiesCreated++;
1947
+ }
1948
+ }
1949
+ let createdBytes = 0;
1950
+ for (const { collectionId, collectionDescription, collectionPolicy, resources, resourcePolicies, resourceMetadata } of collections) {
1951
+ const collectionExisted = Boolean(descriptionsById.get(collectionId));
1952
+ if (collectionExisted) {
1953
+ stats.collectionsSkipped++;
1954
+ }
1955
+ else {
1956
+ // A brand-new Collection row counts against the cap; upserting a
1957
+ // description onto an existing NULL-description placeholder row does
1958
+ // not add a row, so it never trips the limit.
1959
+ const isNewRow = !descriptionsById.has(collectionId);
1960
+ if (maxCollectionsPerSpace !== undefined &&
1961
+ isNewRow &&
1962
+ collectionRowCount >= maxCollectionsPerSpace) {
1963
+ throw new CountQuotaExceededError({
1964
+ scope: 'Collections per Space',
1965
+ limit: maxCollectionsPerSpace
1966
+ });
1967
+ }
1968
+ // Import restores `createdBy` verbatim from the archived document
1969
+ // (already discarded and reapplied by `_upsertCollection`, same as
1970
+ // any other write): this is only ever a create here (the branch
1971
+ // above skips existing Collections), so there is no prior row for
1972
+ // COALESCE to prefer over it.
1973
+ await this._upsertCollection({
1974
+ queryable: client,
1975
+ spaceId,
1976
+ collectionId,
1977
+ collectionDescription,
1978
+ createdBy: collectionDescription.createdBy
1979
+ });
1980
+ if (isNewRow) {
1981
+ collectionRowCount++;
1982
+ }
1983
+ descriptionsById.set(collectionId, collectionDescription);
1984
+ stats.collectionsCreated++;
1985
+ }
1986
+ // A collection-level policy travels with a newly-created collection;
1987
+ // for an existing (skipped) collection, leave its policy untouched.
1988
+ if (collectionPolicy) {
1989
+ if (collectionExisted) {
1990
+ stats.policiesSkipped++;
1991
+ }
1992
+ else {
1993
+ await this._upsertPolicy({
1994
+ queryable: client,
1995
+ spaceId,
1996
+ collectionId,
1997
+ policy: collectionPolicy
1998
+ });
1999
+ stats.policiesCreated++;
2000
+ }
2001
+ }
2002
+ // All ids the destination already holds for this Collection -- live
2003
+ // or tombstone, either blocks the import for its id ("a tombstone
2004
+ // blocks resurrection") -- in one query instead of one per resource.
2005
+ // Created ids are added as we go, so a duplicate id later in the same
2006
+ // archive is skipped rather than tripping the primary key.
2007
+ const { rows: existingIdRows } = await client.query(`SELECT resource_id FROM resources
2008
+ WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
2009
+ const existingResourceIds = new Set(existingIdRows.map(row => row.resource_id));
2010
+ for (const { fileName, resourceId, body } of resources) {
2011
+ if (existingResourceIds.has(resourceId)) {
2012
+ stats.resourcesSkipped++;
2013
+ // A resource-level policy travels with a newly-created resource.
2014
+ if (resourcePolicies.has(resourceId)) {
2015
+ stats.policiesSkipped++;
2016
+ }
2017
+ continue;
2018
+ }
2019
+ // A new live Resource counts against the per-Space cap.
2020
+ if (maxResourcesPerSpace !== undefined) {
2021
+ if (liveResourceCount >= maxResourcesPerSpace) {
2022
+ throw new CountQuotaExceededError({
2023
+ scope: 'Resources per Space',
2024
+ limit: maxResourcesPerSpace
2025
+ });
2026
+ }
2027
+ liveResourceCount++;
2028
+ }
2029
+ const { contentType } = parseResourceFileName(fileName);
2030
+ await this._insertImportedResource({
2031
+ client,
2032
+ spaceId,
2033
+ collectionId,
2034
+ resourceId,
2035
+ contentType,
2036
+ body,
2037
+ sidecar: parseSidecar(resourceMetadata.get(resourceId))
2038
+ });
2039
+ existingResourceIds.add(resourceId);
2040
+ createdBytes += body.length;
2041
+ stats.resourcesCreated++;
2042
+ const resourcePolicy = resourcePolicies.get(resourceId);
2043
+ if (resourcePolicy) {
2044
+ await this._upsertPolicy({
2045
+ queryable: client,
2046
+ spaceId,
2047
+ collectionId,
2048
+ resourceId,
2049
+ policy: resourcePolicy
2050
+ });
2051
+ stats.policiesCreated++;
2052
+ }
2053
+ }
2054
+ // Carry tombstones: an orphan `.meta.` sidecar that is a tombstone
2055
+ // (`deleted: true`) re-creates the tombstone row; a non-tombstone
2056
+ // orphan sidecar is anomalous and skipped. Merge semantics match
2057
+ // resources: anything the destination already has is left untouched.
2058
+ const importedResourceIds = new Set(resources.map(resource => resource.resourceId));
2059
+ for (const resourceId of resourceMetadata.keys()) {
2060
+ if (importedResourceIds.has(resourceId)) {
2061
+ continue;
2062
+ }
2063
+ const sidecar = parseSidecar(resourceMetadata.get(resourceId));
2064
+ if (sidecar?.deleted !== true) {
2065
+ continue;
2066
+ }
2067
+ if (existingResourceIds.has(resourceId)) {
2068
+ stats.resourcesSkipped++;
2069
+ continue;
2070
+ }
2071
+ await this._insertImportedResource({
2072
+ client,
2073
+ spaceId,
2074
+ collectionId,
2075
+ resourceId,
2076
+ contentType: sidecar.contentType ?? 'application/octet-stream',
2077
+ body: null,
2078
+ sidecar
2079
+ });
2080
+ existingResourceIds.add(resourceId);
2081
+ stats.resourcesCreated++;
2082
+ }
2083
+ }
2084
+ if (createdBytes > 0) {
2085
+ // The pre-flight was conservative (it counted skips too), so the
2086
+ // actual created total always fits; apply it unguarded.
2087
+ await this._applyUsageDelta({
2088
+ client,
2089
+ spaceId,
2090
+ delta: createdBytes
2091
+ });
2092
+ }
2093
+ // Restore the archive's Space-scoped zcap revocations under this
2094
+ // Space's scope: a capability revoked before the export must stay
2095
+ // revoked after an import (a backup/restore round-trip must not
2096
+ // resurrect revoked access). `ON CONFLICT DO NOTHING` gives the
2097
+ // skip-not-overwrite merge per record; a record past its GC horizon is
2098
+ // dropped (the capability itself has expired; `isRevoked` would prune
2099
+ // it). Transactional like the rest of the apply loop.
2100
+ const now = Date.now();
2101
+ for (const record of revocations) {
2102
+ if (record.meta.expires && Date.parse(record.meta.expires) <= now) {
2103
+ continue;
2104
+ }
2105
+ await client.query(`INSERT INTO space_revocations
2106
+ (space_id, delegator, capability_id, record, expires)
2107
+ VALUES ($1, $2, $3, $4::jsonb, $5)
2108
+ ON CONFLICT DO NOTHING`, [
2109
+ spaceId,
2110
+ record.meta.delegator,
2111
+ record.capability.id,
2112
+ JSON.stringify(record),
2113
+ record.meta.expires ?? null
2114
+ ]);
2115
+ }
2116
+ return stats;
2117
+ });
2118
+ }
2119
+ /**
2120
+ * Inserts one archived resource (or orphan tombstone) row for the import
2121
+ * apply loop. Timestamps, versions, `createdBy`, and `custom` come from the
2122
+ * archive's sidecar when present; an archive resource without a sidecar is
2123
+ * treated as a fresh first write on this backend (version 1, no
2124
+ * `createdBy`).
2125
+ * @param options {object}
2126
+ * @param options.client {pg.PoolClient}
2127
+ * @param options.spaceId {string}
2128
+ * @param options.collectionId {string}
2129
+ * @param options.resourceId {string}
2130
+ * @param options.contentType {string}
2131
+ * @param options.body {Buffer|null} `null` re-creates a tombstone
2132
+ * @param [options.sidecar] {SidecarShape}
2133
+ * @returns {Promise<void>}
2134
+ */
2135
+ async _insertImportedResource({ client, spaceId, collectionId, resourceId, contentType, body, sidecar }) {
2136
+ const now = new Date().toISOString();
2137
+ const deleted = body === null;
2138
+ await client.query(`INSERT INTO resources (
2139
+ space_id, collection_id, resource_id, content_type, content,
2140
+ is_json, size_bytes, version, meta_version, custom, deleted,
2141
+ created_at, updated_at, created_by, epoch
2142
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11,
2143
+ $12, $13, $14, $15)`, [
2144
+ spaceId,
2145
+ collectionId,
2146
+ resourceId,
2147
+ contentType,
2148
+ body,
2149
+ isJson({ contentType }),
2150
+ body?.length ?? 0,
2151
+ sidecar?.version ?? 1,
2152
+ sidecar?.metaVersion ?? null,
2153
+ sidecar?.custom !== undefined ? JSON.stringify(sidecar.custom) : null,
2154
+ deleted,
2155
+ sidecar?.createdAt ?? now,
2156
+ sidecar?.updatedAt ?? now,
2157
+ sidecar?.createdBy ?? null,
2158
+ // Restore the client-declared key epoch (the `key-epochs` feature) from
2159
+ // the archived sidecar; a tombstone or an unstamped Resource has none.
2160
+ sidecar?.epoch ?? null
2161
+ ]);
2162
+ }
2163
+ }
2164
+ //# sourceMappingURL=postgres.js.map