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,3073 @@
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 type { FastifyBaseLogger } from 'fastify'
30
+ import * as tar from 'tar-stream'
31
+ import YAML from 'yaml'
32
+ import {
33
+ StorageError,
34
+ ResourceNotFoundError,
35
+ SpaceNotFoundError,
36
+ QuotaExceededError,
37
+ CountQuotaExceededError,
38
+ PayloadTooLargeError,
39
+ PreconditionFailedError,
40
+ KeystoreStateConflictError,
41
+ KeyIdConflictError,
42
+ DuplicateRevocationError
43
+ } from '../errors.js'
44
+ import { applyMigrations } from './postgresSchema.js'
45
+ import { extractTarEntries, buildImportPlan } from '../lib/importTar.js'
46
+ import { collectionPath, resourcePath } from '../lib/paths.js'
47
+ import { fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js'
48
+ import { sanitizeBackendRecord } from '../lib/backends.js'
49
+ import { backendUsageFields } from '../lib/backendUsage.js'
50
+ import { assertEncryptedWriteConforms } from '../lib/encryption.js'
51
+ import { encodeCursor, decodeCursor } from '../lib/cursor.js'
52
+ import {
53
+ buildExportManifest,
54
+ EXPORT_ENTRY_MTIME
55
+ } from '../lib/exportManifest.js'
56
+ import { revocationFileName } from '../lib/revocations.js'
57
+ import { isJson } from '../lib/isJson.js'
58
+ import { normalizeDescriptionWrite } from '../lib/collectionDescription.js'
59
+ import { DEFAULT_PAGE_SIZE, clampPageSize } from '../lib/pagination.js'
60
+ import {
61
+ runBlindedIndexQuery,
62
+ collectUniqueBlindedTerms,
63
+ assertNoUniqueBlindedConflict
64
+ } from '../lib/blindedIndex.js'
65
+ import type {
66
+ BlindedIndexQuery,
67
+ BlindedIndexQueryPage
68
+ } from '../lib/blindedIndex.js'
69
+ import {
70
+ DEFAULT_MAX_UPLOAD_BYTES,
71
+ DEFAULT_MAX_SPACES_PER_CONTROLLER,
72
+ DEFAULT_MAX_COLLECTIONS_PER_SPACE,
73
+ DEFAULT_MAX_RESOURCES_PER_SPACE,
74
+ normalizeCountLimit
75
+ } from '../config.default.js'
76
+ import {
77
+ assertWritePrecondition,
78
+ assertMetaWritePrecondition,
79
+ assertCollectionWritePrecondition
80
+ } from '../lib/preconditions.js'
81
+ import type {
82
+ SpaceDescription,
83
+ CollectionDescription,
84
+ CollectionSummary,
85
+ CollectionResourcesList,
86
+ ResourceResult,
87
+ ResourceMetadata,
88
+ ResourceMetadataCustom,
89
+ ResourceInput,
90
+ ImportStats,
91
+ PolicyDocument,
92
+ BackendDescriptor,
93
+ BackendUsage,
94
+ CollectionUsage,
95
+ StorageBackend,
96
+ StoredBackendRecord,
97
+ KeystoreConfig,
98
+ KmsKeyRecord,
99
+ RevocationRecord,
100
+ RevocationScope,
101
+ CapabilitySummary,
102
+ IDID
103
+ } from '../types.js'
104
+
105
+ /** Pool sizing and per-connection statement timeout (operational defaults). */
106
+ const POOL_MAX = 10
107
+ const STATEMENT_TIMEOUT_MS = 30_000
108
+
109
+ /**
110
+ * Silent logger used when no logger is injected into the backend (`createApp`
111
+ * wires `fastify.log` in; tests may leave it silent).
112
+ */
113
+ const silentLogger: FastifyBaseLogger = pino({ level: 'silent' })
114
+
115
+ /**
116
+ * Anything a query can run against: the pool (auto-checkout) or a checked-out
117
+ * transaction client. Shared write helpers take this so the normal methods
118
+ * and the import transaction reuse one statement.
119
+ */
120
+ type Queryable = pg.Pool | pg.PoolClient
121
+
122
+ /**
123
+ * One `resources` row, as read back from pg. `size_bytes` arrives as a string
124
+ * (node-postgres returns `bigint` columns as strings).
125
+ */
126
+ interface ResourceRow {
127
+ content_type: string
128
+ content: Buffer | null
129
+ is_json: boolean
130
+ size_bytes: string
131
+ version: number
132
+ meta_version: number | null
133
+ custom: ResourceMetadataCustom | Record<string, unknown> | null
134
+ deleted: boolean
135
+ created_at: string
136
+ updated_at: string
137
+ created_by: IDID | null
138
+ epoch: string | null
139
+ }
140
+
141
+ /**
142
+ * The synthesized `.meta.<resourceId>.json` sidecar shape used by
143
+ * export/import -- the same on-disk shape the filesystem backend persists
144
+ * (`MetaSidecar` there), so archives stay interchangeable.
145
+ */
146
+ interface SidecarShape {
147
+ createdAt: string
148
+ updatedAt: string
149
+ createdBy?: IDID
150
+ version?: number
151
+ metaVersion?: number
152
+ custom?: ResourceMetadataCustom | Record<string, unknown>
153
+ epoch?: string
154
+ deleted?: boolean
155
+ contentType?: string
156
+ }
157
+
158
+ /**
159
+ * Buffers a byte stream fully into memory, aborting with
160
+ * `PayloadTooLargeError` (413) the moment the cumulative size exceeds
161
+ * `maxUploadBytes` -- the buffered-`bytea` analogue of the filesystem
162
+ * backend's streaming `_uploadCapGuard`.
163
+ * @param options {object}
164
+ * @param options.stream {Readable}
165
+ * @param options.maxUploadBytes {number}
166
+ * @param options.backendId {string} for the 413 problem detail
167
+ * @returns {Promise<Buffer>}
168
+ */
169
+ async function bufferStreamCapped({
170
+ stream,
171
+ maxUploadBytes,
172
+ backendId
173
+ }: {
174
+ stream: Readable
175
+ maxUploadBytes: number
176
+ backendId: string
177
+ }): Promise<Buffer> {
178
+ const chunks: Buffer[] = []
179
+ let total = 0
180
+ for await (const chunk of stream) {
181
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
182
+ total += buffer.length
183
+ if (total > maxUploadBytes) {
184
+ throw new PayloadTooLargeError({ maxUploadBytes, backendId })
185
+ }
186
+ chunks.push(buffer)
187
+ }
188
+ return Buffer.concat(chunks)
189
+ }
190
+
191
+ /**
192
+ * Parses an archived `.meta.<resourceId>.json` sidecar's bytes into the
193
+ * shared sidecar shape; unparseable (or absent) bytes yield `undefined`, and
194
+ * the import falls back to fresh-write defaults.
195
+ * @param bytes {Buffer|undefined}
196
+ * @returns {SidecarShape|undefined}
197
+ */
198
+ function parseSidecar(bytes: Buffer | undefined): SidecarShape | undefined {
199
+ if (!bytes) {
200
+ return undefined
201
+ }
202
+ try {
203
+ return JSON.parse(bytes.toString('utf8')) as SidecarShape
204
+ } catch {
205
+ return undefined
206
+ }
207
+ }
208
+
209
+ export class PostgresBackend implements StorageBackend {
210
+ logger: FastifyBaseLogger
211
+ /**
212
+ * Per-Space storage capacity, in bytes (spec "Quotas"). `undefined` means no
213
+ * configured limit. Unlike the filesystem backend's `du`-sampled soft limit,
214
+ * this is enforced transactionally on every content write -- a HARD limit
215
+ * under concurrency.
216
+ */
217
+ capacityBytes?: number
218
+ /**
219
+ * Largest single upload accepted, in bytes (spec "Quotas",
220
+ * `maxUploadBytes`). Always set on this backend: an unconfigured cap
221
+ * defaults to `DEFAULT_MAX_UPLOAD_BYTES`, because every blob write buffers
222
+ * through memory on the single-`bytea` path.
223
+ */
224
+ maxUploadBytes: number
225
+ /**
226
+ * Max Spaces a single controller may create (spec "Quotas", a default-on
227
+ * count quota). `undefined` means no cap. Enforced transactionally on the
228
+ * Space create path (`writeSpace`), serialized per controller by an advisory
229
+ * lock -- a HARD limit under concurrency, like the byte quota. The
230
+ * constructor normalizes an unset option to
231
+ * {@link DEFAULT_MAX_SPACES_PER_CONTROLLER} and a non-finite option
232
+ * (`Infinity`) to `undefined`.
233
+ */
234
+ maxSpacesPerController?: number
235
+ /**
236
+ * Max Collections a single Space may hold (spec "Quotas", a default-on count
237
+ * quota). `undefined` means no cap. Enforced on the Collection create path,
238
+ * serialized per Space by the space row lock. Normalized like
239
+ * {@link maxSpacesPerController}.
240
+ */
241
+ maxCollectionsPerSpace?: number
242
+ /**
243
+ * Max live Resources a single Space may hold across all its Collections (spec
244
+ * "Quotas", a default-on count quota). `undefined` means no cap. Enforced on
245
+ * the Resource create path (a tombstone does not count). Normalized like
246
+ * {@link maxSpacesPerController}.
247
+ */
248
+ maxResourcesPerSpace?: number
249
+
250
+ private _pool: pg.Pool
251
+ private _schema?: string
252
+
253
+ /**
254
+ * @param options {object}
255
+ * @param options.connectionString {string} a `postgres://` URL
256
+ * @param [options.schema] {string} Postgres schema to operate in (set as
257
+ * the connection `search_path`; created by `init()` if absent). Used for
258
+ * test isolation; production uses the default `public`.
259
+ * @param [options.logger] {FastifyBaseLogger}
260
+ * @param [options.capacityBytes] {number} per-Space quota in bytes; a
261
+ * finite value is enforced, `undefined` or a non-finite value means no
262
+ * configured limit
263
+ * @param [options.maxUploadBytes] {number} per-upload cap in bytes;
264
+ * `undefined` applies the `DEFAULT_MAX_UPLOAD_BYTES` default. A non-finite
265
+ * value (`Infinity`, from `MAX_UPLOAD_BYTES=unlimited`) throws: this backend
266
+ * buffers each upload in memory as a single `bytea`, so an unbounded cap is
267
+ * not supported.
268
+ * @param [options.maxSpacesPerController] {number} max Spaces per
269
+ * controller (spec "Quotas"); `undefined` applies the default-on limit,
270
+ * `Infinity` means no cap
271
+ * @param [options.maxCollectionsPerSpace] {number} max Collections per
272
+ * Space; `undefined` applies the default-on limit, `Infinity` means no cap
273
+ * @param [options.maxResourcesPerSpace] {number} max live Resources per
274
+ * Space; `undefined` applies the default-on limit, `Infinity` means no cap
275
+ */
276
+ constructor({
277
+ connectionString,
278
+ schema,
279
+ logger,
280
+ capacityBytes,
281
+ maxUploadBytes,
282
+ maxSpacesPerController,
283
+ maxCollectionsPerSpace,
284
+ maxResourcesPerSpace
285
+ }: {
286
+ connectionString: string
287
+ schema?: string
288
+ logger?: FastifyBaseLogger
289
+ capacityBytes?: number
290
+ maxUploadBytes?: number
291
+ maxSpacesPerController?: number
292
+ maxCollectionsPerSpace?: number
293
+ maxResourcesPerSpace?: number
294
+ }) {
295
+ if (schema !== undefined && !/^[a-z_][a-z0-9_]*$/i.test(schema)) {
296
+ throw new Error(`Invalid Postgres schema name: "${schema}".`)
297
+ }
298
+ this._schema = schema
299
+ this.logger = logger ?? silentLogger
300
+ // A non-finite `capacityBytes` (`Infinity` from an explicit `unlimited`)
301
+ // behaves exactly like unset inside the backend: no configured limit.
302
+ this.capacityBytes =
303
+ capacityBytes !== undefined && Number.isFinite(capacityBytes)
304
+ ? capacityBytes
305
+ : undefined
306
+ // This backend buffers each upload in memory as a single `bytea`, so an
307
+ // unbounded per-upload cap is not supported -- fail fast at construction
308
+ // rather than risk an OOM at write time.
309
+ if (maxUploadBytes !== undefined && !Number.isFinite(maxUploadBytes)) {
310
+ throw new Error(
311
+ `PostgresBackend does not support an unlimited per-upload cap ` +
312
+ `(MAX_UPLOAD_BYTES=unlimited): each upload is buffered in memory as ` +
313
+ `a single bytea. Set MAX_UPLOAD_BYTES to a finite byte count.`
314
+ )
315
+ }
316
+ this.maxUploadBytes = maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES
317
+ // Count quotas normalize like `maxUploadBytes` (unset applies the
318
+ // default-on limit, a non-finite `Infinity` means no cap), so every guard
319
+ // keeps its plain `!== undefined` test.
320
+ this.maxSpacesPerController = normalizeCountLimit(
321
+ maxSpacesPerController,
322
+ DEFAULT_MAX_SPACES_PER_CONTROLLER
323
+ )
324
+ this.maxCollectionsPerSpace = normalizeCountLimit(
325
+ maxCollectionsPerSpace,
326
+ DEFAULT_MAX_COLLECTIONS_PER_SPACE
327
+ )
328
+ this.maxResourcesPerSpace = normalizeCountLimit(
329
+ maxResourcesPerSpace,
330
+ DEFAULT_MAX_RESOURCES_PER_SPACE
331
+ )
332
+ this._pool = new pg.Pool({
333
+ connectionString,
334
+ max: POOL_MAX,
335
+ statement_timeout: STATEMENT_TIMEOUT_MS,
336
+ // `search_path` is a connection-startup parameter, so every pooled
337
+ // connection lands in the right schema with no per-checkout SET race.
338
+ ...(schema !== undefined && { options: `-csearch_path=${schema}` })
339
+ })
340
+ this._pool.on('error', err => {
341
+ this.logger.error({ err }, 'Postgres pool background error')
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Connects and applies schema migrations (idempotent, advisory-locked; see
347
+ * postgresSchema.ts). Called once by the `createApp` composition before the
348
+ * server starts listening.
349
+ * @returns {Promise<void>}
350
+ */
351
+ async init(): Promise<void> {
352
+ const client = await this._pool.connect()
353
+ try {
354
+ if (this._schema !== undefined) {
355
+ // Identifier-quoted; the constructor validated the name's charset.
356
+ await client.query(`CREATE SCHEMA IF NOT EXISTS "${this._schema}"`)
357
+ }
358
+ // Lift the pool's statement timeout for this session: a waiting
359
+ // instance blocks on the migration advisory lock for as long as the
360
+ // holder's migration takes, and a future slow migration must not be
361
+ // capped at the request-path timeout either.
362
+ await client.query('SET statement_timeout = 0')
363
+ await applyMigrations({ client })
364
+ } finally {
365
+ // Destroy rather than pool-return the client, so the lifted timeout
366
+ // never leaks into a request-path connection.
367
+ client.release(true)
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Drains the connection pool. Wired to the Fastify `onClose` hook by the
373
+ * plugin composition.
374
+ * @returns {Promise<void>}
375
+ */
376
+ async close(): Promise<void> {
377
+ await this._pool.end()
378
+ }
379
+
380
+ /**
381
+ * Runs `fn` inside one transaction on a dedicated client, committing on
382
+ * success and rolling back on any throw.
383
+ * @param fn {(client: pg.PoolClient) => Promise<T>}
384
+ * @returns {Promise<T>}
385
+ */
386
+ private async _withTransaction<T>(
387
+ fn: (client: pg.PoolClient) => Promise<T>
388
+ ): Promise<T> {
389
+ const client = await this._pool.connect()
390
+ try {
391
+ await client.query('BEGIN')
392
+ const result = await fn(client)
393
+ await client.query('COMMIT')
394
+ return result
395
+ } catch (err) {
396
+ try {
397
+ await client.query('ROLLBACK')
398
+ } catch (rollbackErr) {
399
+ this.logger.error({ err: rollbackErr }, 'Postgres rollback failed')
400
+ }
401
+ throw err
402
+ } finally {
403
+ client.release()
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Self-description advertised at `GET /space/:spaceId/backends`. Same
409
+ * affordances as the filesystem backend: conditional writes (ETag
410
+ * validators, row-locked preconditions) and the `changes` and
411
+ * `blinded-index` query profiles.
412
+ * @returns {Required<Omit<BackendDescriptor, 'provider' | 'connection'>>}
413
+ */
414
+ describe(): Required<Omit<BackendDescriptor, 'provider' | 'connection'>> {
415
+ return {
416
+ id: 'default',
417
+ name: 'Server PostgreSQL',
418
+ managedBy: 'server',
419
+ storageMode: ['document', 'blob'],
420
+ persistence: 'durable',
421
+ // `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
422
+ // keys on the `encryption` marker, a client-declared `epoch` stamp on
423
+ // Resources, and conditional (`If-Match`) Collection Description writes.
424
+ features: [
425
+ 'conditional-writes',
426
+ 'changes-query',
427
+ 'blinded-index-query',
428
+ 'key-epochs'
429
+ ]
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Ensures the `spaces` row for `spaceId` exists (a placeholder with a NULL
435
+ * description when the Space was never described -- the analogue of the
436
+ * filesystem creating a Space directory on a sub-Space write).
437
+ * @param options {object}
438
+ * @param options.client {pg.PoolClient}
439
+ * @param options.spaceId {string}
440
+ * @returns {Promise<void>}
441
+ */
442
+ private async _ensureSpaceRow({
443
+ client,
444
+ spaceId
445
+ }: {
446
+ client: pg.PoolClient
447
+ spaceId: string
448
+ }): Promise<void> {
449
+ await client.query(
450
+ `INSERT INTO spaces (space_id) VALUES ($1)
451
+ ON CONFLICT (space_id) DO NOTHING`,
452
+ [spaceId]
453
+ )
454
+ }
455
+
456
+ /**
457
+ * Ensures the `collections` row (and its parent `spaces` row) exists,
458
+ * placeholder-description like `_ensureSpaceRow`.
459
+ * @param options {object}
460
+ * @param options.client {pg.PoolClient}
461
+ * @param options.spaceId {string}
462
+ * @param options.collectionId {string}
463
+ * @returns {Promise<void>}
464
+ */
465
+ private async _ensureCollectionRow({
466
+ client,
467
+ spaceId,
468
+ collectionId
469
+ }: {
470
+ client: pg.PoolClient
471
+ spaceId: string
472
+ collectionId: string
473
+ }): Promise<void> {
474
+ await this._ensureSpaceRow({ client, spaceId })
475
+ await client.query(
476
+ `INSERT INTO collections (space_id, collection_id) VALUES ($1, $2)
477
+ ON CONFLICT (space_id, collection_id) DO NOTHING`,
478
+ [spaceId, collectionId]
479
+ )
480
+ }
481
+
482
+ /**
483
+ * Applies a usage delta to the Space's transactional quota counter,
484
+ * enforcing the configured capacity in the same statement (the hard-limit
485
+ * departure from the filesystem's `du`-sampled soft check). Zero rows
486
+ * updated with the row present means the write would not fit:
487
+ * `QuotaExceededError` (507), rolling back the enclosing transaction.
488
+ * MUST run inside the same transaction as the content mutation.
489
+ * @param options {object}
490
+ * @param options.client {pg.PoolClient}
491
+ * @param options.spaceId {string}
492
+ * @param options.delta {number} signed byte delta (new minus old size)
493
+ * @returns {Promise<void>}
494
+ */
495
+ private async _applyUsageDelta({
496
+ client,
497
+ spaceId,
498
+ delta
499
+ }: {
500
+ client: pg.PoolClient
501
+ spaceId: string
502
+ delta: number
503
+ }): Promise<void> {
504
+ // Only a growing write can exhaust the quota; shrinking writes and deletes
505
+ // always apply (and clamp at zero so drift can never go negative).
506
+ const cap = delta > 0 ? (this.capacityBytes ?? null) : null
507
+ const result = await client.query(
508
+ `UPDATE spaces
509
+ SET usage_bytes = GREATEST(usage_bytes + $2, 0)
510
+ WHERE space_id = $1
511
+ AND ($3::bigint IS NULL OR usage_bytes + $2 <= $3::bigint)`,
512
+ [spaceId, delta, cap]
513
+ )
514
+ if (result.rowCount === 0) {
515
+ throw new QuotaExceededError({
516
+ spaceId,
517
+ capacityBytes: this.capacityBytes!
518
+ })
519
+ }
520
+ }
521
+
522
+ // Quotas
523
+
524
+ /**
525
+ * Reports the Space's usage from the transactional counter (no measurement
526
+ * pass, no cache). The per-Collection breakdown is computed on demand with
527
+ * one aggregate query.
528
+ * @param options {object}
529
+ * @param options.spaceId {string}
530
+ * @param [options.includeCollections] {boolean}
531
+ * @returns {Promise<BackendUsage>}
532
+ */
533
+ async reportUsage({
534
+ spaceId,
535
+ includeCollections = false
536
+ }: {
537
+ spaceId: string
538
+ includeCollections?: boolean
539
+ }): Promise<BackendUsage> {
540
+ const measuredAt = new Date().toISOString()
541
+ const { rows } = await this._pool.query<{ usage_bytes: string }>(
542
+ 'SELECT usage_bytes FROM spaces WHERE space_id = $1',
543
+ [spaceId]
544
+ )
545
+ const usageBytes = rows[0] ? Number(rows[0].usage_bytes) : 0
546
+
547
+ let usageByCollection: CollectionUsage[] | undefined
548
+ if (includeCollections) {
549
+ const { rows: collectionRows } = await this._pool.query<{
550
+ collection_id: string
551
+ usage: string
552
+ }>(
553
+ `SELECT collection_id, COALESCE(SUM(size_bytes), 0) AS usage
554
+ FROM resources
555
+ WHERE space_id = $1
556
+ GROUP BY collection_id
557
+ ORDER BY collection_id`,
558
+ [spaceId]
559
+ )
560
+ usageByCollection = collectionRows.map(row => ({
561
+ id: row.collection_id,
562
+ usageBytes: Number(row.usage)
563
+ }))
564
+ }
565
+
566
+ return {
567
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes: usageBytes }),
568
+ measuredAt,
569
+ ...(includeCollections && { usageByCollection })
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Reports a single Collection's usage (its `SUM(size_bytes)` slice), with
575
+ * `state` / `limit` derived from the Space total (the quota is a per-Space
576
+ * limit).
577
+ * @param options {object}
578
+ * @param options.spaceId {string}
579
+ * @param options.collectionId {string}
580
+ * @returns {Promise<BackendUsage>}
581
+ */
582
+ async reportCollectionUsage({
583
+ spaceId,
584
+ collectionId
585
+ }: {
586
+ spaceId: string
587
+ collectionId: string
588
+ }): Promise<BackendUsage> {
589
+ const measuredAt = new Date().toISOString()
590
+ const { rows } = await this._pool.query<{
591
+ space_total: string
592
+ collection_total: string
593
+ }>(
594
+ `SELECT
595
+ (SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
596
+ AS space_total,
597
+ (SELECT COALESCE(SUM(size_bytes), 0) FROM resources
598
+ WHERE space_id = $1 AND collection_id = $2) AS collection_total`,
599
+ [spaceId, collectionId]
600
+ )
601
+ const spaceTotalBytes = Number(rows[0]?.space_total ?? 0)
602
+ const usageBytes = Number(rows[0]?.collection_total ?? 0)
603
+ return {
604
+ ...this._backendUsageFields({ usageBytes, spaceTotalBytes }),
605
+ measuredAt
606
+ }
607
+ }
608
+
609
+ /**
610
+ * Builds the shared quota-report condition fields (see
611
+ * `lib/backendUsage.ts`).
612
+ * @param options {object}
613
+ * @param options.usageBytes {number}
614
+ * @param options.spaceTotalBytes {number}
615
+ * @returns {Omit<BackendUsage, 'measuredAt' | 'usageByCollection'>}
616
+ */
617
+ private _backendUsageFields({
618
+ usageBytes,
619
+ spaceTotalBytes
620
+ }: {
621
+ usageBytes: number
622
+ spaceTotalBytes: number
623
+ }): Omit<BackendUsage, 'measuredAt' | 'usageByCollection'> {
624
+ const { id, name, managedBy } = this.describe()
625
+ return backendUsageFields({
626
+ usageBytes,
627
+ spaceTotalBytes,
628
+ capacityBytes: this.capacityBytes,
629
+ maxUploadBytes: this.maxUploadBytes,
630
+ id,
631
+ name,
632
+ managedBy
633
+ })
634
+ }
635
+
636
+ // Spaces
637
+
638
+ /**
639
+ * @param options {object}
640
+ * @param options.spaceId {string}
641
+ * @param options.spaceDescription {SpaceDescription}
642
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
643
+ * Space's `createdBy` on first write only
644
+ * @returns {Promise<void>}
645
+ */
646
+ async writeSpace({
647
+ spaceId,
648
+ spaceDescription,
649
+ createdBy
650
+ }: {
651
+ spaceId: string
652
+ spaceDescription: SpaceDescription
653
+ createdBy?: IDID
654
+ }): Promise<void> {
655
+ const { controller } = spaceDescription
656
+ // `createdBy` names the Space's creator, not its last writer: taken from
657
+ // this write's invoker only when there is no prior description, and
658
+ // preserved verbatim afterward. The client-supplied `spaceDescription` is
659
+ // wire input and may carry its own `createdBy` -- discard it, since the
660
+ // server alone is authoritative for this field.
661
+ const { createdBy: _suppliedCreatedBy, ...rest } = spaceDescription
662
+ const descriptionJson = JSON.stringify(rest)
663
+ // The upsert maintains the denormalized `controller` column on both insert
664
+ // and update -- the description's controller can change on update, and the
665
+ // Spaces count quota reads this column (spec "Quotas"). `createdBy` is
666
+ // resolved within this same statement, in one transaction-free round trip
667
+ // (no separate read-then-write race with a concurrent `writeSpace` for the
668
+ // same id):
669
+ // - `spaces.description IS NULL` means there is no prior description row
670
+ // (a placeholder row created by a sub-resource write before any Space
671
+ // Description was written) -- this write IS the create, so it behaves
672
+ // like the insert branch: attach this write's `createdBy` when present,
673
+ // otherwise omit the key entirely (never store it as JSON `null`).
674
+ // - Otherwise a prior description exists, and its `createdBy` -- present
675
+ // or absent -- is preserved verbatim via the jsonb `?` key-existence
676
+ // operator; this write's `createdBy` is ignored entirely (never
677
+ // backfilled).
678
+ const upsert = (queryable: Queryable): Promise<unknown> =>
679
+ queryable.query(
680
+ `INSERT INTO spaces (space_id, description, controller)
681
+ VALUES (
682
+ $1,
683
+ CASE WHEN $4::text IS NULL THEN $2::jsonb
684
+ ELSE ($2::jsonb) || jsonb_build_object('createdBy', $4::text) END,
685
+ $3
686
+ )
687
+ ON CONFLICT (space_id) DO UPDATE SET
688
+ description = CASE
689
+ WHEN spaces.description IS NULL THEN
690
+ CASE WHEN $4::text IS NULL THEN $2::jsonb
691
+ ELSE ($2::jsonb) || jsonb_build_object('createdBy', $4::text) END
692
+ WHEN spaces.description ? 'createdBy' THEN
693
+ ($2::jsonb) || jsonb_build_object(
694
+ 'createdBy', spaces.description->>'createdBy'
695
+ )
696
+ ELSE $2::jsonb
697
+ END,
698
+ controller = EXCLUDED.controller`,
699
+ [spaceId, descriptionJson, controller, createdBy ?? null]
700
+ )
701
+
702
+ if (this.maxSpacesPerController === undefined) {
703
+ await upsert(this._pool)
704
+ return
705
+ }
706
+
707
+ // Count quota (create path only), enforced as a HARD limit: take a
708
+ // controller-scoped advisory lock so concurrent creates for the same
709
+ // controller serialize (the byte quota's posture), detect a create (no
710
+ // described row yet -- a NULL-description placeholder counts as a create),
711
+ // COUNT this controller's Spaces, and reject at the limit.
712
+ await this._withTransaction(async client => {
713
+ await client.query(
714
+ `SELECT pg_advisory_xact_lock(hashtext('controller-count:' || $1))`,
715
+ [controller]
716
+ )
717
+ const { rows } = await client.query<{ description: unknown }>(
718
+ 'SELECT description FROM spaces WHERE space_id = $1',
719
+ [spaceId]
720
+ )
721
+ const isCreate = rows[0]?.description == null
722
+ if (isCreate) {
723
+ const { rows: countRows } = await client.query<{ count: number }>(
724
+ 'SELECT COUNT(*)::int AS count FROM spaces WHERE controller = $1',
725
+ [controller]
726
+ )
727
+ if (countRows[0]!.count >= this.maxSpacesPerController!) {
728
+ throw new CountQuotaExceededError({
729
+ scope: 'Spaces per controller',
730
+ limit: this.maxSpacesPerController!
731
+ })
732
+ }
733
+ }
734
+ await upsert(client)
735
+ })
736
+ }
737
+
738
+ /**
739
+ * @param options {object}
740
+ * @param options.spaceId {string}
741
+ * @returns {Promise<SpaceDescription|undefined>} falsy when the Space does
742
+ * not exist or is a placeholder row without a description
743
+ */
744
+ async getSpaceDescription({
745
+ spaceId
746
+ }: {
747
+ spaceId: string
748
+ }): Promise<SpaceDescription | undefined> {
749
+ const { rows } = await this._pool.query<{
750
+ description: SpaceDescription | null
751
+ }>('SELECT description FROM spaces WHERE space_id = $1', [spaceId])
752
+ return rows[0]?.description ?? undefined
753
+ }
754
+
755
+ /**
756
+ * Deletes the Space row; collections, resources, policies, and backend
757
+ * records go with it via `ON DELETE CASCADE` (keystores are a sibling tree
758
+ * and are deliberately untouched). Idempotent.
759
+ * @param options {object}
760
+ * @param options.spaceId {string}
761
+ * @returns {Promise<void>}
762
+ */
763
+ async deleteSpace({ spaceId }: { spaceId: string }): Promise<void> {
764
+ await this._pool.query('DELETE FROM spaces WHERE space_id = $1', [spaceId])
765
+ }
766
+
767
+ /**
768
+ * Every described Space, sorted by id (byte order via `COLLATE "C"`).
769
+ * Placeholder rows without a description are skipped, like a Space dir
770
+ * without a readable description file.
771
+ * @returns {Promise<SpaceDescription[]>}
772
+ */
773
+ async listSpaces(): Promise<SpaceDescription[]> {
774
+ const { rows } = await this._pool.query<{
775
+ description: SpaceDescription
776
+ }>(
777
+ `SELECT description FROM spaces
778
+ WHERE description IS NOT NULL
779
+ ORDER BY space_id`
780
+ )
781
+ return rows.map(row => row.description)
782
+ }
783
+
784
+ // Collections
785
+
786
+ /**
787
+ * @param options {object}
788
+ * @param options.spaceId {string}
789
+ * @param options.collectionId {string}
790
+ * @param options.collectionDescription {CollectionDescription}
791
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
792
+ * Collection's `createdBy` on first write only
793
+ * @param [options.ifMatch] {string} an `If-Match` compare-and-swap on the
794
+ * current description version (the `key-epochs` feature); a stale validator
795
+ * throws `PreconditionFailedError` (412)
796
+ * @returns {Promise<{ version: number }>} the Collection's new description
797
+ * version (the `ETag` validator)
798
+ */
799
+ async writeCollection({
800
+ spaceId,
801
+ collectionId,
802
+ collectionDescription,
803
+ createdBy,
804
+ ifMatch,
805
+ assertTransition
806
+ }: {
807
+ spaceId: string
808
+ collectionId: string
809
+ collectionDescription: CollectionDescription
810
+ createdBy?: IDID
811
+ ifMatch?: string
812
+ assertTransition?: (
813
+ prior?: CollectionDescription & { descriptionVersion?: number }
814
+ ) => void
815
+ }): Promise<{ version: number }> {
816
+ return this._withTransaction(async client => {
817
+ await this._ensureSpaceRow({ client, spaceId })
818
+ // Serialize all Collection writes within the Space on its space row: the
819
+ // collection-row `FOR UPDATE` below locks nothing when the row does not
820
+ // exist yet, so without this two concurrent creates of *different* new
821
+ // ids could each pass the create-path quota COUNT (overshooting
822
+ // `maxCollectionsPerSpace`), and two creates of the *same* id could each
823
+ // compute the same first version.
824
+ await client.query(
825
+ 'SELECT 1 FROM spaces WHERE space_id = $1 FOR UPDATE',
826
+ [spaceId]
827
+ )
828
+ // Lock the Collection row (if any) and read its current description and
829
+ // version, so the `If-Match` compare-and-swap, the transition rails, the
830
+ // create detection, and the monotonic version bump are all atomic with
831
+ // the write (two concurrent recipient edits cannot clobber one another).
832
+ const { rows } = await client.query<{
833
+ description: CollectionDescription | null
834
+ description_version: number
835
+ }>(
836
+ `SELECT description, description_version FROM collections
837
+ WHERE space_id = $1 AND collection_id = $2 FOR UPDATE`,
838
+ [spaceId, collectionId]
839
+ )
840
+ const priorRow = rows[0]
841
+ // A missing row and a placeholder (NULL-description) row are both "no
842
+ // described Collection yet": version 0, so the first real description
843
+ // write returns 1 (a placeholder's `description_version` column holds
844
+ // the schema DEFAULT and must not count).
845
+ const prior =
846
+ priorRow?.description == null
847
+ ? undefined
848
+ : {
849
+ ...priorRow.description,
850
+ descriptionVersion: priorRow.description_version
851
+ }
852
+ const currentVersion = prior?.descriptionVersion ?? 0
853
+ // Compare-and-swap (opt-in): a stale `If-Match` throws 412. An
854
+ // unconditional write skips this.
855
+ assertCollectionWritePrecondition({
856
+ collectionId,
857
+ currentVersion,
858
+ ifMatch
859
+ })
860
+ // The request layer's state-transition rails (e.g. epoch append-only),
861
+ // re-evaluated here against the row just read under the lock.
862
+ assertTransition?.(prior)
863
+ // Count quota (create path only): a create is no row or a placeholder
864
+ // (NULL-description) row; describing one must not push the Space past
865
+ // `maxCollectionsPerSpace` (spec "Quotas").
866
+ if (this.maxCollectionsPerSpace !== undefined && prior === undefined) {
867
+ const { rows: countRows } = await client.query<{ count: number }>(
868
+ 'SELECT COUNT(*)::int AS count FROM collections WHERE space_id = $1',
869
+ [spaceId]
870
+ )
871
+ if (countRows[0]!.count >= this.maxCollectionsPerSpace) {
872
+ throw new CountQuotaExceededError({
873
+ scope: 'Collections per Space',
874
+ limit: this.maxCollectionsPerSpace
875
+ })
876
+ }
877
+ }
878
+ const version = currentVersion + 1
879
+ await this._upsertCollection({
880
+ queryable: client,
881
+ spaceId,
882
+ collectionId,
883
+ collectionDescription,
884
+ createdBy,
885
+ descriptionVersion: version
886
+ })
887
+ return { version }
888
+ })
889
+ }
890
+
891
+ /**
892
+ * The one Collection-description upsert statement, shared by
893
+ * `writeCollection` and the import apply loop. `createdBy` is resolved
894
+ * within this same statement, in one round trip (no separate read-then-write
895
+ * race with a concurrent write for the same id):
896
+ * - `collections.description IS NULL` means there is no prior description
897
+ * row (a placeholder row created by a sub-Resource write before any
898
+ * Collection Description was written) -- this write IS the create, so it
899
+ * behaves like the insert branch: attach the `createdBy` parameter when
900
+ * present, otherwise omit the key entirely (never store it as JSON
901
+ * `null`).
902
+ * - Otherwise a prior description exists, and its `createdBy` -- present or
903
+ * absent -- is preserved verbatim via the jsonb `?` key-existence
904
+ * operator; the `createdBy` parameter is ignored entirely (never
905
+ * backfilled).
906
+ * Any `createdBy` embedded in `collectionDescription` itself is discarded
907
+ * here: `writeCollection` passes the invoker DID via the `createdBy`
908
+ * parameter instead, and the import apply loop passes the imported
909
+ * document's own `createdBy` so a restored Collection keeps its original
910
+ * creator (that import call is always a create -- the caller skips existing
911
+ * Collections -- so there is no prior row to preserve instead).
912
+ * @param options {object}
913
+ * @param options.queryable {Queryable}
914
+ * @param options.spaceId {string}
915
+ * @param options.collectionId {string}
916
+ * @param options.collectionDescription {CollectionDescription}
917
+ * @param [options.createdBy] {string} resolved creator to fall back to
918
+ * when there is no prior row
919
+ * @param [options.descriptionVersion] {number} the monotonic description
920
+ * version to stamp (the `ETag` validator; the `key-epochs` feature), kept
921
+ * OUT of the `description` jsonb -- it lives in the `description_version`
922
+ * column and travels only as the `ETag` header. When omitted (the import
923
+ * path), a `_version` on the incoming archived description is used, else 1.
924
+ * @returns {Promise<void>}
925
+ */
926
+ private async _upsertCollection({
927
+ queryable,
928
+ spaceId,
929
+ collectionId,
930
+ collectionDescription,
931
+ createdBy,
932
+ descriptionVersion
933
+ }: {
934
+ queryable: Queryable
935
+ spaceId: string
936
+ collectionId: string
937
+ collectionDescription: CollectionDescription
938
+ createdBy?: IDID
939
+ descriptionVersion?: number
940
+ }): Promise<void> {
941
+ // Shared normalization (lib/collectionDescription.ts): strip the
942
+ // version-bearing members from the incoming (possibly imported)
943
+ // description so neither lands in the stored jsonb body -- the resolved
944
+ // version becomes the `description_version` column instead. `createdBy` is
945
+ // additionally stripped here because this statement re-resolves it in SQL.
946
+ const { body, version } = normalizeDescriptionWrite({
947
+ collectionDescription,
948
+ descriptionVersion
949
+ })
950
+ const { createdBy: _suppliedCreatedBy, ...rest } = body
951
+ await queryable.query(
952
+ `INSERT INTO collections (space_id, collection_id, description,
953
+ description_version)
954
+ VALUES (
955
+ $1, $2,
956
+ CASE WHEN $4::text IS NULL THEN $3::jsonb
957
+ ELSE ($3::jsonb) || jsonb_build_object('createdBy', $4::text) END,
958
+ $5
959
+ )
960
+ ON CONFLICT (space_id, collection_id) DO UPDATE SET
961
+ description = CASE
962
+ WHEN collections.description IS NULL THEN
963
+ CASE WHEN $4::text IS NULL THEN $3::jsonb
964
+ ELSE ($3::jsonb) || jsonb_build_object('createdBy', $4::text) END
965
+ WHEN collections.description ? 'createdBy' THEN
966
+ ($3::jsonb) || jsonb_build_object(
967
+ 'createdBy', collections.description->>'createdBy'
968
+ )
969
+ ELSE $3::jsonb
970
+ END,
971
+ description_version = $5`,
972
+ [spaceId, collectionId, JSON.stringify(rest), createdBy ?? null, version]
973
+ )
974
+ }
975
+
976
+ /**
977
+ * @param options {object}
978
+ * @param options.spaceId {string}
979
+ * @param options.collectionId {string}
980
+ * @returns {Promise<(CollectionDescription & { descriptionVersion?: number })
981
+ * | undefined>} `descriptionVersion` is the out-of-band `ETag` validator.
982
+ */
983
+ async getCollectionDescription({
984
+ spaceId,
985
+ collectionId
986
+ }: {
987
+ spaceId: string
988
+ collectionId: string
989
+ }): Promise<
990
+ (CollectionDescription & { descriptionVersion?: number }) | undefined
991
+ > {
992
+ const { rows } = await this._pool.query<{
993
+ description: CollectionDescription | null
994
+ description_version: number
995
+ }>(
996
+ `SELECT description, description_version FROM collections
997
+ WHERE space_id = $1 AND collection_id = $2`,
998
+ [spaceId, collectionId]
999
+ )
1000
+ const row = rows[0]
1001
+ if (!row?.description) {
1002
+ return undefined
1003
+ }
1004
+ // Surface the version out-of-band as `descriptionVersion` (the handler sets
1005
+ // the `ETag` header from it); it is stored in its own column, never the
1006
+ // wire body.
1007
+ return { ...row.description, descriptionVersion: row.description_version }
1008
+ }
1009
+
1010
+ /**
1011
+ * Deletes the Collection row (resources cascade), its policies, and
1012
+ * subtracts the Collection's stored bytes from the Space usage counter --
1013
+ * all in one transaction. Idempotent.
1014
+ * @param options {object}
1015
+ * @param options.spaceId {string}
1016
+ * @param options.collectionId {string}
1017
+ * @returns {Promise<void>}
1018
+ */
1019
+ async deleteCollection({
1020
+ spaceId,
1021
+ collectionId
1022
+ }: {
1023
+ spaceId: string
1024
+ collectionId: string
1025
+ }): Promise<void> {
1026
+ await this._withTransaction(async client => {
1027
+ const { rows } = await client.query<{ total: string }>(
1028
+ `SELECT COALESCE(SUM(size_bytes), 0) AS total FROM resources
1029
+ WHERE space_id = $1 AND collection_id = $2`,
1030
+ [spaceId, collectionId]
1031
+ )
1032
+ const freedBytes = Number(rows[0]?.total ?? 0)
1033
+ await client.query(
1034
+ `DELETE FROM collections WHERE space_id = $1 AND collection_id = $2`,
1035
+ [spaceId, collectionId]
1036
+ )
1037
+ // Collection- and Resource-level policies live under the Collection (the
1038
+ // filesystem removes them with the dir; here they key off collection_id).
1039
+ await client.query(
1040
+ `DELETE FROM policies WHERE space_id = $1 AND collection_id = $2`,
1041
+ [spaceId, collectionId]
1042
+ )
1043
+ if (freedBytes > 0) {
1044
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
1045
+ }
1046
+ })
1047
+ }
1048
+
1049
+ /**
1050
+ * Every Collection row in the Space, sorted by id. A row without a
1051
+ * description (created by a sub-Collection write) falls back to the id for
1052
+ * its `name`, like a description-less directory on the filesystem.
1053
+ * @param options {object}
1054
+ * @param options.spaceId {string}
1055
+ * @returns {Promise<CollectionSummary[]>}
1056
+ */
1057
+ async listCollections({
1058
+ spaceId
1059
+ }: {
1060
+ spaceId: string
1061
+ }): Promise<CollectionSummary[]> {
1062
+ const { rows } = await this._pool.query<{
1063
+ collection_id: string
1064
+ description: CollectionDescription | null
1065
+ }>(
1066
+ `SELECT collection_id, description FROM collections
1067
+ WHERE space_id = $1 ORDER BY collection_id`,
1068
+ [spaceId]
1069
+ )
1070
+ return rows.map(row => ({
1071
+ id: row.collection_id,
1072
+ url: collectionPath({ spaceId, collectionId: row.collection_id }),
1073
+ name: row.description?.name ?? row.collection_id
1074
+ }))
1075
+ }
1076
+
1077
+ /**
1078
+ * Lists a Collection's Resources, cursor-paginated with the same keyset
1079
+ * (ascending `resourceId`, byte order), cursor codec, clamps, and `next`
1080
+ * construction as the filesystem backend. Tombstones are invisible.
1081
+ * @param options {object}
1082
+ * @param options.spaceId {string}
1083
+ * @param options.collectionId {string}
1084
+ * @param [options.limit] {number}
1085
+ * @param [options.cursor] {string}
1086
+ * @param [options.collectionDescription] {CollectionDescription}
1087
+ * @returns {Promise<CollectionResourcesList>}
1088
+ */
1089
+ async listCollectionItems({
1090
+ spaceId,
1091
+ collectionId,
1092
+ limit,
1093
+ cursor,
1094
+ collectionDescription: providedDescription
1095
+ }: {
1096
+ spaceId: string
1097
+ collectionId: string
1098
+ limit?: number
1099
+ cursor?: string
1100
+ collectionDescription?: CollectionDescription
1101
+ }): Promise<CollectionResourcesList> {
1102
+ const collectionDescription =
1103
+ providedDescription ??
1104
+ (await this.getCollectionDescription({ spaceId, collectionId }))
1105
+
1106
+ const after = cursor !== undefined ? decodeCursor(cursor).after : undefined
1107
+ const pageSize =
1108
+ limit === undefined ? DEFAULT_PAGE_SIZE : clampPageSize(limit)
1109
+
1110
+ const { rows: countRows } = await this._pool.query<{ total: string }>(
1111
+ `SELECT COUNT(*) AS total FROM resources
1112
+ WHERE space_id = $1 AND collection_id = $2 AND NOT deleted`,
1113
+ [spaceId, collectionId]
1114
+ )
1115
+ const totalItems = Number(countRows[0]?.total ?? 0)
1116
+
1117
+ // Take `pageSize + 1` from the seek point to detect a further page without
1118
+ // a second query; `hasMore` is whether the extra row arrived.
1119
+ const { rows } = await this._pool.query<{
1120
+ resource_id: string
1121
+ content_type: string
1122
+ custom: ResourceMetadataCustom | null
1123
+ epoch: string | null
1124
+ }>(
1125
+ `SELECT resource_id, content_type, custom, epoch FROM resources
1126
+ WHERE space_id = $1 AND collection_id = $2 AND NOT deleted
1127
+ AND ($3::text IS NULL OR resource_id > $3)
1128
+ ORDER BY resource_id
1129
+ LIMIT $4`,
1130
+ [spaceId, collectionId, after ?? null, pageSize + 1]
1131
+ )
1132
+ const hasMore = rows.length > pageSize
1133
+ const pageRows = hasMore ? rows.slice(0, pageSize) : rows
1134
+
1135
+ // Surface the user-writable `custom.name` only for a plaintext Collection;
1136
+ // on an encrypted one `custom` is the opaque envelope, so the listing
1137
+ // omits `name` (spec "List Collection", encrypted-Collection note).
1138
+ const encrypted = collectionDescription?.encryption !== undefined
1139
+ const items = pageRows.map(row => {
1140
+ const name = encrypted ? undefined : row.custom?.name
1141
+ return {
1142
+ id: row.resource_id,
1143
+ url: resourcePath({
1144
+ spaceId,
1145
+ collectionId,
1146
+ resourceId: row.resource_id
1147
+ }),
1148
+ contentType: row.content_type,
1149
+ ...(name !== undefined && { name }),
1150
+ // The client-declared key epoch (the `key-epochs` feature) rides each
1151
+ // listing item so a reader picks the right epoch key without a `/meta`
1152
+ // fetch per Resource.
1153
+ ...(row.epoch !== null && { epoch: row.epoch })
1154
+ }
1155
+ })
1156
+
1157
+ let next: string | undefined
1158
+ if (hasMore) {
1159
+ const lastId = pageRows[pageRows.length - 1]!.resource_id
1160
+ const base = collectionPath({
1161
+ spaceId,
1162
+ collectionId,
1163
+ trailingSlash: true
1164
+ })
1165
+ next = `${base}?limit=${pageSize}&cursor=${encodeCursor(lastId)}`
1166
+ }
1167
+
1168
+ return {
1169
+ id: collectionId,
1170
+ url: collectionPath({ spaceId, collectionId }),
1171
+ name: collectionDescription?.name ?? collectionId,
1172
+ type: collectionDescription?.type || ['Collection'],
1173
+ totalItems,
1174
+ items,
1175
+ ...(next !== undefined && { next })
1176
+ }
1177
+ }
1178
+
1179
+ // Resources
1180
+
1181
+ /**
1182
+ * Writes a Resource representation as one transaction: row lock, shared
1183
+ * precondition evaluation (exact filesystem semantics -- a tombstone counts
1184
+ * as "not exists", `ifNoneMatch` precedence per RFC9110), monotonic
1185
+ * `version` bump continuing through delete/recreate, and the transactional
1186
+ * quota delta. JSON is stored as its serialized UTF-8 bytes; blobs buffer
1187
+ * through the capped accumulator.
1188
+ * @param options {object}
1189
+ * @param options.spaceId {string}
1190
+ * @param options.collectionId {string}
1191
+ * @param options.resourceId {string}
1192
+ * @param options.input {ResourceInput}
1193
+ * @param [options.createdBy] {string} DID of the invoker, recorded as the
1194
+ * Resource's `createdBy` on first write only
1195
+ * @param [options.ifMatch] {string}
1196
+ * @param [options.ifNoneMatch] {boolean}
1197
+ * @returns {Promise<{ version: number }>}
1198
+ */
1199
+ async writeResource({
1200
+ spaceId,
1201
+ collectionId,
1202
+ resourceId,
1203
+ input,
1204
+ createdBy,
1205
+ epoch,
1206
+ ifMatch,
1207
+ ifNoneMatch
1208
+ }: {
1209
+ spaceId: string
1210
+ collectionId: string
1211
+ resourceId: string
1212
+ input: ResourceInput
1213
+ createdBy?: IDID
1214
+ epoch?: string
1215
+ ifMatch?: string
1216
+ ifNoneMatch?: boolean
1217
+ }): Promise<{ version: number }> {
1218
+ const { maxUploadBytes } = this
1219
+ let content: Buffer
1220
+ if (input.kind === 'json') {
1221
+ content = Buffer.from(JSON.stringify(input.data))
1222
+ if (content.length > maxUploadBytes) {
1223
+ throw new PayloadTooLargeError({
1224
+ maxUploadBytes,
1225
+ backendId: this.describe().id,
1226
+ uploadBytes: content.length
1227
+ })
1228
+ }
1229
+ } else {
1230
+ // Pre-flight a declared size, then buffer through the counting guard
1231
+ // that hard-caps a body whose size is omitted or understated. Buffering
1232
+ // happens BEFORE the transaction so a slow upload holds no row lock.
1233
+ if (
1234
+ input.declaredBytes !== undefined &&
1235
+ input.declaredBytes > maxUploadBytes
1236
+ ) {
1237
+ throw new PayloadTooLargeError({
1238
+ maxUploadBytes,
1239
+ backendId: this.describe().id,
1240
+ uploadBytes: input.declaredBytes
1241
+ })
1242
+ }
1243
+ content = await bufferStreamCapped({
1244
+ stream: input.stream,
1245
+ maxUploadBytes,
1246
+ backendId: this.describe().id
1247
+ })
1248
+ }
1249
+
1250
+ return this._withTransaction(async client => {
1251
+ await this._ensureCollectionRow({ client, spaceId, collectionId })
1252
+
1253
+ // The EDV unique-attribute invariant (`unique: true` blinded attributes;
1254
+ // the `blinded-index-query` feature): a write claiming a unique blinded
1255
+ // triple must not collide with another live document's claim. Only a
1256
+ // unique-carrying JSON write can create a new claim, so only such writes
1257
+ // pay for it: a per-Collection transaction-scoped advisory lock
1258
+ // serializes the claimants (held to commit, so the loser's scan sees the
1259
+ // winner's committed row) without entering the row-lock ordering of
1260
+ // plain writes, then the conflict is evaluated against the Collection's
1261
+ // other live JSON documents.
1262
+ if (
1263
+ input.kind === 'json' &&
1264
+ collectUniqueBlindedTerms({ document: input.data }).length > 0
1265
+ ) {
1266
+ await client.query(
1267
+ 'SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))',
1268
+ [spaceId, collectionId]
1269
+ )
1270
+ const { rows: candidateRows } = await client.query<{
1271
+ resource_id: string
1272
+ content: Buffer | null
1273
+ }>(
1274
+ `SELECT resource_id, content FROM resources
1275
+ WHERE space_id = $1 AND collection_id = $2 AND is_json
1276
+ AND NOT deleted AND resource_id <> $3`,
1277
+ [spaceId, collectionId, resourceId]
1278
+ )
1279
+ const candidates: Array<{ resourceId: string; document: unknown }> = []
1280
+ for (const row of candidateRows) {
1281
+ if (!row.content) {
1282
+ continue
1283
+ }
1284
+ try {
1285
+ candidates.push({
1286
+ resourceId: row.resource_id,
1287
+ document: JSON.parse(row.content.toString('utf8')) as unknown
1288
+ })
1289
+ } catch {
1290
+ // skip an unparsable body
1291
+ }
1292
+ }
1293
+ assertNoUniqueBlindedConflict({ document: input.data, candidates })
1294
+ }
1295
+
1296
+ // Narrow projection: the lock needs the row, not its (possibly multi-MB)
1297
+ // `content` bytea, which this path never reads.
1298
+ const { rows } = await client.query<
1299
+ Pick<
1300
+ ResourceRow,
1301
+ 'version' | 'size_bytes' | 'deleted' | 'created_at' | 'created_by'
1302
+ >
1303
+ >(
1304
+ `SELECT version, size_bytes, deleted, created_at, created_by
1305
+ FROM resources
1306
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1307
+ FOR UPDATE`,
1308
+ [spaceId, collectionId, resourceId]
1309
+ )
1310
+ const prior = rows[0]
1311
+ const exists = prior !== undefined && !prior.deleted
1312
+ if (ifMatch !== undefined || ifNoneMatch) {
1313
+ assertWritePrecondition({
1314
+ resourceId,
1315
+ exists,
1316
+ currentVersion: prior?.version ?? 0,
1317
+ ifMatch,
1318
+ ifNoneMatch
1319
+ })
1320
+ }
1321
+
1322
+ // Count quota (create path only): a new live Resource -- including one
1323
+ // written over a tombstone (`exists` is false) -- must not push the Space
1324
+ // past `maxResourcesPerSpace` (spec "Quotas"). An overwrite of a live
1325
+ // Resource never trips it. Counted inside the write transaction.
1326
+ if (this.maxResourcesPerSpace !== undefined && !exists) {
1327
+ const { rows: countRows } = await client.query<{ count: number }>(
1328
+ `SELECT COUNT(*)::int AS count FROM resources
1329
+ WHERE space_id = $1 AND NOT deleted`,
1330
+ [spaceId]
1331
+ )
1332
+ if (countRows[0]!.count >= this.maxResourcesPerSpace) {
1333
+ throw new CountQuotaExceededError({
1334
+ scope: 'Resources per Space',
1335
+ limit: this.maxResourcesPerSpace
1336
+ })
1337
+ }
1338
+ }
1339
+
1340
+ const now = new Date().toISOString()
1341
+ const version = (prior?.version ?? 0) + 1
1342
+ const priorSize = exists ? Number(prior.size_bytes) : 0
1343
+ const delta = content.length - priorSize
1344
+ if (delta !== 0) {
1345
+ await this._applyUsageDelta({ client, spaceId, delta })
1346
+ }
1347
+
1348
+ // A content write preserves the independent `metaVersion` and the
1349
+ // user-writable `custom` of a LIVE Resource; a tombstoned row already
1350
+ // dropped both (the metadata went with the deleted Resource).
1351
+ //
1352
+ // Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
1353
+ // (a tombstone is a real row and stays lock-serialized), the SELECT FOR
1354
+ // UPDATE locked nothing -- READ COMMITTED has no gap locks -- so a
1355
+ // concurrent creator may have raced past the same precondition. A plain
1356
+ // INSERT (no ON CONFLICT) makes the primary key the arbiter: the loser's
1357
+ // unique violation maps to the 412 the precondition would have thrown.
1358
+ // `createdBy` names the Resource's creator, not its last writer: taken
1359
+ // from this write's invoker only when there is no prior row at all
1360
+ // (`prior === undefined`), then preserved EXACTLY as the prior row has
1361
+ // it -- including preserved-as-absent -- by every later write,
1362
+ // regardless of who invokes it. A tombstone IS a prior row, so a
1363
+ // re-create over one keeps the tombstone's `createdBy` (or its
1364
+ // absence), exactly as `created_at` is preserved across it.
1365
+ const creator =
1366
+ prior !== undefined ? prior.created_by : (createdBy ?? null)
1367
+ const values = [
1368
+ spaceId,
1369
+ collectionId,
1370
+ resourceId,
1371
+ input.contentType,
1372
+ content,
1373
+ isJson({ contentType: input.contentType }),
1374
+ content.length,
1375
+ version,
1376
+ prior?.created_at ?? now,
1377
+ creator,
1378
+ // The client-declared key epoch (the `key-epochs` feature): a content
1379
+ // write stores it and CLEARS it when absent (the new ciphertext's epoch
1380
+ // is unknown), so both the INSERT and the conflict update set it from
1381
+ // this write -- it is NOT preserved from the prior row like `created_by`.
1382
+ epoch ?? null
1383
+ ]
1384
+ const insertSql = `
1385
+ INSERT INTO resources (
1386
+ space_id, collection_id, resource_id, content_type, content,
1387
+ is_json, size_bytes, version, meta_version, custom, deleted,
1388
+ created_at, updated_at, created_by, epoch
1389
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, NULL, false, $9, $9, $10, $11)`
1390
+ if (ifNoneMatch && prior === undefined) {
1391
+ try {
1392
+ await client.query(insertSql, values)
1393
+ } catch (err) {
1394
+ if ((err as { code?: string }).code === '23505') {
1395
+ throw new PreconditionFailedError({
1396
+ detail: `Resource '${resourceId}' already exists (If-None-Match: *).`
1397
+ })
1398
+ }
1399
+ throw err
1400
+ }
1401
+ } else {
1402
+ // The conflict update derives `version` from the row (`resources.
1403
+ // version + 1`), not from the pre-read: if a concurrent creator
1404
+ // slipped in after our lock-nothing SELECT, the counter still
1405
+ // advances monotonically instead of two writers both claiming
1406
+ // version 1 (an ETag anomaly). RETURNING reports the version that
1407
+ // actually landed.
1408
+ const { rows: written } = await client.query<{ version: number }>(
1409
+ `${insertSql}
1410
+ ON CONFLICT (space_id, collection_id, resource_id) DO UPDATE SET
1411
+ content_type = EXCLUDED.content_type,
1412
+ content = EXCLUDED.content,
1413
+ is_json = EXCLUDED.is_json,
1414
+ size_bytes = EXCLUDED.size_bytes,
1415
+ version = resources.version + 1,
1416
+ deleted = false,
1417
+ updated_at = EXCLUDED.updated_at,
1418
+ created_by = resources.created_by,
1419
+ epoch = EXCLUDED.epoch
1420
+ RETURNING version`,
1421
+ values
1422
+ )
1423
+ return { version: written[0]!.version }
1424
+ }
1425
+ // `created_at` / `meta_version` / `custom` are deliberately NOT in the
1426
+ // conflict update: an overwrite keeps the original creation time (also
1427
+ // across a tombstone, as the filesystem sidecar does) and the metadata
1428
+ // counters as they stand on the row. `created_by` is likewise NOT
1429
+ // backfilled from `EXCLUDED`: the conflict path always means a prior
1430
+ // row already existed (including the race where a concurrent creator's
1431
+ // INSERT landed between our lock-nothing SELECT and this statement), so
1432
+ // `resources.created_by` -- the prior row's own value, absent or not --
1433
+ // is authoritative and this write's `createdBy` is ignored entirely.
1434
+ return { version }
1435
+ })
1436
+ }
1437
+
1438
+ /**
1439
+ * @param options {object}
1440
+ * @param options.spaceId {string}
1441
+ * @param options.collectionId {string}
1442
+ * @param options.resourceId {string}
1443
+ * @param [options.contentType] {string} advisory; ignored for lookup
1444
+ * @returns {Promise<ResourceResult>}
1445
+ */
1446
+ async getResource({
1447
+ spaceId,
1448
+ collectionId,
1449
+ resourceId
1450
+ }: {
1451
+ spaceId: string
1452
+ collectionId: string
1453
+ resourceId: string
1454
+ contentType?: string
1455
+ }): Promise<ResourceResult> {
1456
+ const { rows } = await this._pool.query<ResourceRow>(
1457
+ `SELECT content_type, content, version, deleted FROM resources
1458
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1459
+ [spaceId, collectionId, resourceId]
1460
+ )
1461
+ const row = rows[0]
1462
+ if (!row || row.deleted || row.content === null) {
1463
+ throw new ResourceNotFoundError({ requestName: 'Get Resource' })
1464
+ }
1465
+ return {
1466
+ resourceStream: Readable.from(row.content),
1467
+ storedResourceType: row.content_type,
1468
+ version: row.version
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Soft-deletes a Resource into a tombstone row: content dropped, `deleted`
1474
+ * set, `version` bumped (so the change feed surfaces it), last-known
1475
+ * `content_type` retained, `custom` / `meta_version` dropped, and the freed
1476
+ * bytes subtracted from the quota counter -- one transaction. Idempotent on
1477
+ * an absent Resource or an existing tombstone.
1478
+ * @param options {object}
1479
+ * @param options.spaceId {string}
1480
+ * @param options.collectionId {string}
1481
+ * @param options.resourceId {string}
1482
+ * @param [options.ifMatch] {string}
1483
+ * @returns {Promise<void>}
1484
+ */
1485
+ async deleteResource({
1486
+ spaceId,
1487
+ collectionId,
1488
+ resourceId,
1489
+ ifMatch
1490
+ }: {
1491
+ spaceId: string
1492
+ collectionId: string
1493
+ resourceId: string
1494
+ ifMatch?: string
1495
+ }): Promise<void> {
1496
+ await this._withTransaction(async client => {
1497
+ // Narrow projection: the lock needs the row, not the `content` bytea
1498
+ // that is about to be dropped anyway.
1499
+ const { rows } = await client.query<
1500
+ Pick<ResourceRow, 'version' | 'size_bytes' | 'deleted'>
1501
+ >(
1502
+ `SELECT version, size_bytes, deleted FROM resources
1503
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1504
+ FOR UPDATE`,
1505
+ [spaceId, collectionId, resourceId]
1506
+ )
1507
+ const prior = rows[0]
1508
+ const exists = prior !== undefined && !prior.deleted
1509
+ if (ifMatch !== undefined) {
1510
+ assertWritePrecondition({
1511
+ resourceId,
1512
+ exists,
1513
+ currentVersion: prior?.version ?? 0,
1514
+ ifMatch
1515
+ })
1516
+ }
1517
+ if (!exists) {
1518
+ // Already absent (never existed, or already a tombstone): idempotent
1519
+ // no-op, keeping an existing tombstone's change-feed entry stable.
1520
+ return
1521
+ }
1522
+ const freedBytes = Number(prior.size_bytes)
1523
+ if (freedBytes > 0) {
1524
+ await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
1525
+ }
1526
+ const now = new Date().toISOString()
1527
+ await client.query(
1528
+ `UPDATE resources SET
1529
+ content = NULL,
1530
+ size_bytes = 0,
1531
+ version = version + 1,
1532
+ meta_version = NULL,
1533
+ custom = NULL,
1534
+ epoch = NULL,
1535
+ deleted = true,
1536
+ updated_at = $4
1537
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1538
+ [spaceId, collectionId, resourceId, now]
1539
+ )
1540
+ })
1541
+ }
1542
+
1543
+ /**
1544
+ * Reads the metadata of a Resource's current representation. Tombstones and
1545
+ * absent Resources resolve `undefined`. `custom` is included only when
1546
+ * non-empty, verbatim (`{ name, tags }` or the opaque envelope).
1547
+ * @param options {object}
1548
+ * @param options.spaceId {string}
1549
+ * @param options.collectionId {string}
1550
+ * @param options.resourceId {string}
1551
+ * @returns {Promise<(ResourceMetadata & { createdBy?: IDID; version?:
1552
+ * number; metaVersion?: number }) | undefined>}
1553
+ */
1554
+ async getResourceMetadata({
1555
+ spaceId,
1556
+ collectionId,
1557
+ resourceId
1558
+ }: {
1559
+ spaceId: string
1560
+ collectionId: string
1561
+ resourceId: string
1562
+ }): Promise<
1563
+ | (ResourceMetadata & {
1564
+ createdBy?: IDID
1565
+ version?: number
1566
+ metaVersion?: number
1567
+ })
1568
+ | undefined
1569
+ > {
1570
+ const { rows } = await this._pool.query<ResourceRow>(
1571
+ `SELECT content_type, size_bytes, version, meta_version, custom, epoch,
1572
+ deleted, created_at, updated_at, created_by
1573
+ FROM resources
1574
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1575
+ [spaceId, collectionId, resourceId]
1576
+ )
1577
+ const row = rows[0]
1578
+ if (!row || row.deleted) {
1579
+ return undefined
1580
+ }
1581
+ const hasCustom = row.custom !== null && Object.keys(row.custom).length > 0
1582
+ return {
1583
+ contentType: row.content_type,
1584
+ size: Number(row.size_bytes),
1585
+ createdAt: row.created_at,
1586
+ updatedAt: row.updated_at,
1587
+ // Absent for a Resource created before `createdBy` was recorded.
1588
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1589
+ ...(hasCustom && { custom: row.custom as ResourceMetadataCustom }),
1590
+ // The client-declared key epoch (the `key-epochs` feature), when stamped.
1591
+ ...(row.epoch !== null && { epoch: row.epoch }),
1592
+ version: row.version,
1593
+ ...(row.meta_version !== null && { metaVersion: row.meta_version })
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * Replaces the user-writable `custom` object (full replacement; `{}`
1599
+ * clears), bumping `updatedAt` and the independent `metaVersion` -- one
1600
+ * row-locked transaction, preconditions evaluated on the current
1601
+ * `metaVersion` via the shared helper. Resolves `undefined` (no create) for
1602
+ * an absent or tombstoned Resource.
1603
+ * @param options {object}
1604
+ * @param options.spaceId {string}
1605
+ * @param options.collectionId {string}
1606
+ * @param options.resourceId {string}
1607
+ * @param options.custom {ResourceMetadataCustom | Record<string, unknown>}
1608
+ * @param [options.ifMatch] {string}
1609
+ * @param [options.ifNoneMatch] {boolean}
1610
+ * @returns {Promise<{ metaVersion: number } | undefined>}
1611
+ */
1612
+ async writeResourceMetadata({
1613
+ spaceId,
1614
+ collectionId,
1615
+ resourceId,
1616
+ custom,
1617
+ epoch,
1618
+ ifMatch,
1619
+ ifNoneMatch
1620
+ }: {
1621
+ spaceId: string
1622
+ collectionId: string
1623
+ resourceId: string
1624
+ custom: ResourceMetadataCustom | Record<string, unknown>
1625
+ epoch?: string
1626
+ ifMatch?: string
1627
+ ifNoneMatch?: boolean
1628
+ }): Promise<{ metaVersion: number } | undefined> {
1629
+ return this._withTransaction(async client => {
1630
+ const { rows } = await client.query<ResourceRow>(
1631
+ `SELECT meta_version, deleted FROM resources
1632
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
1633
+ FOR UPDATE`,
1634
+ [spaceId, collectionId, resourceId]
1635
+ )
1636
+ const prior = rows[0]
1637
+ if (!prior || prior.deleted) {
1638
+ return undefined
1639
+ }
1640
+ assertMetaWritePrecondition({
1641
+ resourceId,
1642
+ metaVersion: prior.meta_version ?? undefined,
1643
+ ifMatch,
1644
+ ifNoneMatch
1645
+ })
1646
+ const metaVersion = (prior.meta_version ?? 0) + 1
1647
+ const hasCustom = Object.keys(custom).length > 0
1648
+ const now = new Date().toISOString()
1649
+ // The key-epoch stamp describes the CONTENT write, so a supplied `epoch`
1650
+ // replaces it but an OMITTED one PRESERVES the stored value (unlike
1651
+ // `custom`, full-replace): `COALESCE($7, epoch)` keeps the current value
1652
+ // when the parameter is NULL.
1653
+ await client.query(
1654
+ `UPDATE resources SET
1655
+ meta_version = $4,
1656
+ custom = $5::jsonb,
1657
+ updated_at = $6,
1658
+ epoch = COALESCE($7, epoch)
1659
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1660
+ [
1661
+ spaceId,
1662
+ collectionId,
1663
+ resourceId,
1664
+ metaVersion,
1665
+ hasCustom ? JSON.stringify(custom) : null,
1666
+ now,
1667
+ epoch ?? null
1668
+ ]
1669
+ )
1670
+ return { metaVersion }
1671
+ })
1672
+ }
1673
+
1674
+ /**
1675
+ * Replication change feed (the `changes` query profile): one indexed keyset
1676
+ * query over `(updatedAt, resourceId)`, tombstones included, JSON documents
1677
+ * only, bodies parsed for the returned page.
1678
+ * @param options {object}
1679
+ * @param options.spaceId {string}
1680
+ * @param options.collectionId {string}
1681
+ * @param [options.checkpoint] {{ id: string, updatedAt: string }}
1682
+ * @param options.limit {number}
1683
+ * @returns {Promise<{ documents: Array<object>, checkpoint: object | null }>}
1684
+ */
1685
+ async changesSince({
1686
+ spaceId,
1687
+ collectionId,
1688
+ checkpoint,
1689
+ limit
1690
+ }: {
1691
+ spaceId: string
1692
+ collectionId: string
1693
+ checkpoint?: { id: string; updatedAt: string }
1694
+ limit: number
1695
+ }): Promise<{
1696
+ documents: Array<{
1697
+ resourceId: string
1698
+ version: number
1699
+ metaVersion?: number
1700
+ createdBy?: IDID
1701
+ updatedAt: string
1702
+ deleted: boolean
1703
+ data?: unknown
1704
+ custom?: ResourceMetadataCustom | Record<string, unknown>
1705
+ epoch?: string
1706
+ }>
1707
+ checkpoint: { id: string; updatedAt: string } | null
1708
+ }> {
1709
+ const pageSize = clampPageSize(limit)
1710
+ const { rows } = await this._pool.query<
1711
+ ResourceRow & { resource_id: string }
1712
+ >(
1713
+ `SELECT resource_id, content, version, meta_version, custom, epoch,
1714
+ deleted, updated_at, created_by
1715
+ FROM resources
1716
+ WHERE space_id = $1 AND collection_id = $2 AND is_json
1717
+ AND ($3::text IS NULL OR (updated_at, resource_id) > ($3, $4))
1718
+ ORDER BY updated_at, resource_id
1719
+ LIMIT $5`,
1720
+ [
1721
+ spaceId,
1722
+ collectionId,
1723
+ checkpoint?.updatedAt ?? null,
1724
+ checkpoint?.id ?? null,
1725
+ pageSize
1726
+ ]
1727
+ )
1728
+
1729
+ const documents = rows.map(row => {
1730
+ if (row.deleted) {
1731
+ return {
1732
+ resourceId: row.resource_id,
1733
+ version: row.version,
1734
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
1735
+ // A tombstone keeps its creator, as it keeps its `created_at`.
1736
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1737
+ updatedAt: row.updated_at,
1738
+ deleted: true
1739
+ }
1740
+ }
1741
+ let data: unknown
1742
+ try {
1743
+ data = row.content
1744
+ ? JSON.parse(row.content.toString('utf8'))
1745
+ : undefined
1746
+ } catch {
1747
+ data = undefined
1748
+ }
1749
+ return {
1750
+ resourceId: row.resource_id,
1751
+ version: row.version,
1752
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
1753
+ // The creator's DID rides the feed so provenance replicates with the
1754
+ // document, rather than needing a `/meta` fetch per Resource.
1755
+ ...(row.created_by !== null && { createdBy: row.created_by }),
1756
+ updatedAt: row.updated_at,
1757
+ deleted: false,
1758
+ data,
1759
+ ...(row.custom !== null && { custom: row.custom }),
1760
+ // The client-declared key epoch (the `key-epochs` feature) rides the
1761
+ // feed so a replicating reader picks the right epoch key.
1762
+ ...(row.epoch !== null && { epoch: row.epoch })
1763
+ }
1764
+ })
1765
+
1766
+ const last = documents[documents.length - 1]
1767
+ return {
1768
+ documents,
1769
+ checkpoint: last
1770
+ ? { id: last.resourceId, updatedAt: last.updatedAt }
1771
+ : null
1772
+ }
1773
+ }
1774
+
1775
+ /**
1776
+ * Blinded-index query (the `blinded-index` query profile; see the
1777
+ * `StorageBackend.queryByBlindedIndex` contract). Selects the Collection's
1778
+ * live JSON rows, parses each body, and hands the candidates to the shared
1779
+ * evaluator (`lib/blindedIndex.ts`) for matching, ordering, and cursor
1780
+ * pagination -- identical semantics to the filesystem backend. A full scan
1781
+ * of the Collection per call, deliberate for this teaching backend; an
1782
+ * indexed variant would flatten the blinded attributes into an indexed
1783
+ * token side-table (the bedrock-edv-storage strategy). Unparsable JSON is
1784
+ * skipped.
1785
+ * @param options {object}
1786
+ * @param options.spaceId {string}
1787
+ * @param options.collectionId {string}
1788
+ * @param options.query {BlindedIndexQuery}
1789
+ * @param [options.count] {boolean} return only the match count
1790
+ * @param [options.limit] {number} requested page size
1791
+ * @param [options.cursor] {string} opaque cursor from a prior page
1792
+ * @returns {Promise<{ count: number } | BlindedIndexQueryPage>}
1793
+ */
1794
+ async queryByBlindedIndex({
1795
+ spaceId,
1796
+ collectionId,
1797
+ query,
1798
+ count,
1799
+ limit,
1800
+ cursor
1801
+ }: {
1802
+ spaceId: string
1803
+ collectionId: string
1804
+ query: BlindedIndexQuery
1805
+ count?: boolean
1806
+ limit?: number
1807
+ cursor?: string
1808
+ }): Promise<{ count: number } | BlindedIndexQueryPage> {
1809
+ const { rows } = await this._pool.query<{
1810
+ resource_id: string
1811
+ content: Buffer | null
1812
+ }>(
1813
+ `SELECT resource_id, content FROM resources
1814
+ WHERE space_id = $1 AND collection_id = $2 AND is_json AND NOT deleted
1815
+ ORDER BY resource_id`,
1816
+ [spaceId, collectionId]
1817
+ )
1818
+
1819
+ const candidates: Array<{ resourceId: string; document: unknown }> = []
1820
+ for (const row of rows) {
1821
+ if (!row.content) {
1822
+ continue
1823
+ }
1824
+ try {
1825
+ candidates.push({
1826
+ resourceId: row.resource_id,
1827
+ document: JSON.parse(row.content.toString('utf8')) as unknown
1828
+ })
1829
+ } catch {
1830
+ // skip an unparsable body
1831
+ }
1832
+ }
1833
+
1834
+ return runBlindedIndexQuery({ candidates, query, count, limit, cursor })
1835
+ }
1836
+
1837
+ // Policies
1838
+
1839
+ /**
1840
+ * Maps the optional-id policy addressing onto the sentinel-column primary
1841
+ * key: Space policy `('', '')`, Collection policy `(cid, '')`, Resource
1842
+ * policy `(cid, rid)`.
1843
+ * @param options {object}
1844
+ * @param [options.collectionId] {string}
1845
+ * @param [options.resourceId] {string}
1846
+ * @returns {{ collectionKey: string, resourceKey: string }}
1847
+ */
1848
+ private _policyKey({
1849
+ collectionId,
1850
+ resourceId
1851
+ }: {
1852
+ collectionId?: string
1853
+ resourceId?: string
1854
+ }): { collectionKey: string; resourceKey: string } {
1855
+ return {
1856
+ collectionKey: collectionId ?? '',
1857
+ resourceKey: resourceId ?? ''
1858
+ }
1859
+ }
1860
+
1861
+ /**
1862
+ * @param options {object}
1863
+ * @param options.spaceId {string}
1864
+ * @param [options.collectionId] {string}
1865
+ * @param [options.resourceId] {string}
1866
+ * @returns {Promise<PolicyDocument|undefined>}
1867
+ */
1868
+ async getPolicy({
1869
+ spaceId,
1870
+ collectionId,
1871
+ resourceId
1872
+ }: {
1873
+ spaceId: string
1874
+ collectionId?: string
1875
+ resourceId?: string
1876
+ }): Promise<PolicyDocument | undefined> {
1877
+ const { collectionKey, resourceKey } = this._policyKey({
1878
+ collectionId,
1879
+ resourceId
1880
+ })
1881
+ const { rows } = await this._pool.query<{ policy: PolicyDocument }>(
1882
+ `SELECT policy FROM policies
1883
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1884
+ [spaceId, collectionKey, resourceKey]
1885
+ )
1886
+ return rows[0]?.policy
1887
+ }
1888
+
1889
+ /**
1890
+ * @param options {object}
1891
+ * @param options.spaceId {string}
1892
+ * @param [options.collectionId] {string}
1893
+ * @param [options.resourceId] {string}
1894
+ * @param options.policy {PolicyDocument}
1895
+ * @returns {Promise<void>}
1896
+ */
1897
+ async writePolicy({
1898
+ spaceId,
1899
+ collectionId,
1900
+ resourceId,
1901
+ policy
1902
+ }: {
1903
+ spaceId: string
1904
+ collectionId?: string
1905
+ resourceId?: string
1906
+ policy: PolicyDocument
1907
+ }): Promise<void> {
1908
+ await this._withTransaction(async client => {
1909
+ // Ensure the containing rows exist (Space, and the Collection when the
1910
+ // policy is below Space level), like the filesystem's dir provisioning.
1911
+ if (collectionId !== undefined) {
1912
+ await this._ensureCollectionRow({ client, spaceId, collectionId })
1913
+ } else {
1914
+ await this._ensureSpaceRow({ client, spaceId })
1915
+ }
1916
+ await this._upsertPolicy({
1917
+ queryable: client,
1918
+ spaceId,
1919
+ collectionId,
1920
+ resourceId,
1921
+ policy
1922
+ })
1923
+ })
1924
+ }
1925
+
1926
+ /**
1927
+ * The one policy upsert statement, shared by `writePolicy` and the import
1928
+ * apply loop; keys through `_policyKey` so the sentinel convention lives in
1929
+ * one place.
1930
+ * @param options {object}
1931
+ * @param options.queryable {Queryable}
1932
+ * @param options.spaceId {string}
1933
+ * @param [options.collectionId] {string}
1934
+ * @param [options.resourceId] {string}
1935
+ * @param options.policy {PolicyDocument}
1936
+ * @returns {Promise<void>}
1937
+ */
1938
+ private async _upsertPolicy({
1939
+ queryable,
1940
+ spaceId,
1941
+ collectionId,
1942
+ resourceId,
1943
+ policy
1944
+ }: {
1945
+ queryable: Queryable
1946
+ spaceId: string
1947
+ collectionId?: string
1948
+ resourceId?: string
1949
+ policy: PolicyDocument
1950
+ }): Promise<void> {
1951
+ const { collectionKey, resourceKey } = this._policyKey({
1952
+ collectionId,
1953
+ resourceId
1954
+ })
1955
+ await queryable.query(
1956
+ `INSERT INTO policies (space_id, collection_id, resource_id, policy)
1957
+ VALUES ($1, $2, $3, $4::jsonb)
1958
+ ON CONFLICT (space_id, collection_id, resource_id)
1959
+ DO UPDATE SET policy = EXCLUDED.policy`,
1960
+ [spaceId, collectionKey, resourceKey, JSON.stringify(policy)]
1961
+ )
1962
+ }
1963
+
1964
+ /**
1965
+ * @param options {object}
1966
+ * @param options.spaceId {string}
1967
+ * @param [options.collectionId] {string}
1968
+ * @param [options.resourceId] {string}
1969
+ * @returns {Promise<void>} idempotent
1970
+ */
1971
+ async deletePolicy({
1972
+ spaceId,
1973
+ collectionId,
1974
+ resourceId
1975
+ }: {
1976
+ spaceId: string
1977
+ collectionId?: string
1978
+ resourceId?: string
1979
+ }): Promise<void> {
1980
+ const { collectionKey, resourceKey } = this._policyKey({
1981
+ collectionId,
1982
+ resourceId
1983
+ })
1984
+ await this._pool.query(
1985
+ `DELETE FROM policies
1986
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
1987
+ [spaceId, collectionKey, resourceKey]
1988
+ )
1989
+ }
1990
+
1991
+ // Registered external backends (spec "Backends")
1992
+
1993
+ /**
1994
+ * Persists a full (secret-bearing) backend-registration record. Upsert.
1995
+ * @param options {object}
1996
+ * @param options.spaceId {string}
1997
+ * @param options.backendId {string}
1998
+ * @param options.record {StoredBackendRecord}
1999
+ * @returns {Promise<void>}
2000
+ */
2001
+ async writeBackend({
2002
+ spaceId,
2003
+ backendId,
2004
+ record
2005
+ }: {
2006
+ spaceId: string
2007
+ backendId: string
2008
+ record: StoredBackendRecord
2009
+ }): Promise<void> {
2010
+ await this._withTransaction(async client => {
2011
+ await this._ensureSpaceRow({ client, spaceId })
2012
+ await client.query(
2013
+ `INSERT INTO backend_records (space_id, backend_id, record)
2014
+ VALUES ($1, $2, $3::jsonb)
2015
+ ON CONFLICT (space_id, backend_id)
2016
+ DO UPDATE SET record = EXCLUDED.record`,
2017
+ [spaceId, backendId, JSON.stringify(record)]
2018
+ )
2019
+ })
2020
+ }
2021
+
2022
+ /**
2023
+ * The full (secret-bearing) record, or `undefined`. The only method that
2024
+ * exposes secret connection material -- internal use.
2025
+ * @param options {object}
2026
+ * @param options.spaceId {string}
2027
+ * @param options.backendId {string}
2028
+ * @returns {Promise<StoredBackendRecord|undefined>}
2029
+ */
2030
+ async getBackend({
2031
+ spaceId,
2032
+ backendId
2033
+ }: {
2034
+ spaceId: string
2035
+ backendId: string
2036
+ }): Promise<StoredBackendRecord | undefined> {
2037
+ const { rows } = await this._pool.query<{ record: StoredBackendRecord }>(
2038
+ `SELECT record FROM backend_records
2039
+ WHERE space_id = $1 AND backend_id = $2`,
2040
+ [spaceId, backendId]
2041
+ )
2042
+ return rows[0]?.record
2043
+ }
2044
+
2045
+ /**
2046
+ * The Space's registered backends, **sanitized** (mapped through
2047
+ * `sanitizeBackendRecord` -- the secret boundary is unchanged), sorted by id.
2048
+ * @param options {object}
2049
+ * @param options.spaceId {string}
2050
+ * @returns {Promise<BackendDescriptor[]>}
2051
+ */
2052
+ async listBackends({
2053
+ spaceId
2054
+ }: {
2055
+ spaceId: string
2056
+ }): Promise<BackendDescriptor[]> {
2057
+ const { rows } = await this._pool.query<{ record: StoredBackendRecord }>(
2058
+ `SELECT record FROM backend_records
2059
+ WHERE space_id = $1 ORDER BY backend_id`,
2060
+ [spaceId]
2061
+ )
2062
+ return rows.map(row => sanitizeBackendRecord(row.record))
2063
+ }
2064
+
2065
+ /**
2066
+ * @param options {object}
2067
+ * @param options.spaceId {string}
2068
+ * @param options.backendId {string}
2069
+ * @returns {Promise<void>} idempotent
2070
+ */
2071
+ async deleteBackend({
2072
+ spaceId,
2073
+ backendId
2074
+ }: {
2075
+ spaceId: string
2076
+ backendId: string
2077
+ }): Promise<void> {
2078
+ await this._pool.query(
2079
+ `DELETE FROM backend_records WHERE space_id = $1 AND backend_id = $2`,
2080
+ [spaceId, backendId]
2081
+ )
2082
+ }
2083
+
2084
+ // WebKMS keystores (the `/kms` facet)
2085
+
2086
+ /**
2087
+ * Persists a keystore config unconditionally (the create path; local ids
2088
+ * are server-generated random values). The queryable/gated fields
2089
+ * (`controller`, `sequence`, `kmsModule`) are denormalized alongside the
2090
+ * verbatim config.
2091
+ * @param options {object}
2092
+ * @param options.keystoreId {string}
2093
+ * @param options.config {KeystoreConfig}
2094
+ * @returns {Promise<void>}
2095
+ */
2096
+ async writeKeystore({
2097
+ keystoreId,
2098
+ config
2099
+ }: {
2100
+ keystoreId: string
2101
+ config: KeystoreConfig
2102
+ }): Promise<void> {
2103
+ await this._pool.query(
2104
+ `INSERT INTO keystores (keystore_id, controller, sequence, kms_module, config)
2105
+ VALUES ($1, $2, $3, $4, $5::jsonb)
2106
+ ON CONFLICT (keystore_id) DO UPDATE SET
2107
+ controller = EXCLUDED.controller,
2108
+ sequence = EXCLUDED.sequence,
2109
+ kms_module = EXCLUDED.kms_module,
2110
+ config = EXCLUDED.config`,
2111
+ [
2112
+ keystoreId,
2113
+ config.controller,
2114
+ config.sequence,
2115
+ config.kmsModule,
2116
+ JSON.stringify(config)
2117
+ ]
2118
+ )
2119
+ }
2120
+
2121
+ /**
2122
+ * @param options {object}
2123
+ * @param options.keystoreId {string}
2124
+ * @returns {Promise<KeystoreConfig|undefined>}
2125
+ */
2126
+ async getKeystore({
2127
+ keystoreId
2128
+ }: {
2129
+ keystoreId: string
2130
+ }): Promise<KeystoreConfig | undefined> {
2131
+ const { rows } = await this._pool.query<{ config: KeystoreConfig }>(
2132
+ 'SELECT config FROM keystores WHERE keystore_id = $1',
2133
+ [keystoreId]
2134
+ )
2135
+ return rows[0]?.config
2136
+ }
2137
+
2138
+ /**
2139
+ * Replaces a keystore config, gated atomically in one conditional `UPDATE`:
2140
+ * the row must exist with `sequence` exactly one less than the incoming
2141
+ * config's and an unchanged `kmsModule`. Zero rows updated -- missing
2142
+ * keystore, stale sequence, or module change alike -- rejects with the
2143
+ * protocol's single merged 409 (`KeystoreStateConflictError`).
2144
+ * @param options {object}
2145
+ * @param options.keystoreId {string}
2146
+ * @param options.config {KeystoreConfig}
2147
+ * @returns {Promise<void>}
2148
+ */
2149
+ async updateKeystore({
2150
+ keystoreId,
2151
+ config
2152
+ }: {
2153
+ keystoreId: string
2154
+ config: KeystoreConfig
2155
+ }): Promise<void> {
2156
+ const result = await this._pool.query(
2157
+ `UPDATE keystores SET
2158
+ controller = $2,
2159
+ sequence = $3,
2160
+ config = $4::jsonb
2161
+ WHERE keystore_id = $1 AND sequence = $3 - 1 AND kms_module = $5`,
2162
+ [
2163
+ keystoreId,
2164
+ config.controller,
2165
+ config.sequence,
2166
+ JSON.stringify(config),
2167
+ config.kmsModule
2168
+ ]
2169
+ )
2170
+ if (result.rowCount === 0) {
2171
+ throw new KeystoreStateConflictError()
2172
+ }
2173
+ }
2174
+
2175
+ /**
2176
+ * Every stored keystore config whose `controller` matches, sorted by local
2177
+ * id (the request layer caps the wire result).
2178
+ * @param options {object}
2179
+ * @param options.controller {IDID}
2180
+ * @returns {Promise<KeystoreConfig[]>}
2181
+ */
2182
+ async listKeystoresByController({
2183
+ controller
2184
+ }: {
2185
+ controller: IDID
2186
+ }): Promise<KeystoreConfig[]> {
2187
+ const { rows } = await this._pool.query<{ config: KeystoreConfig }>(
2188
+ `SELECT config FROM keystores
2189
+ WHERE controller = $1 ORDER BY keystore_id`,
2190
+ [controller]
2191
+ )
2192
+ return rows.map(row => row.config)
2193
+ }
2194
+
2195
+ /**
2196
+ * Inserts a key record, create-only: the primary key enforces the
2197
+ * `(keystoreId, localId)` uniqueness atomically; a duplicate rejects with
2198
+ * the protocol's 409 (`KeyIdConflictError`). The record is stored verbatim
2199
+ * (opaque to storage -- the at-rest cipher applies above the backend).
2200
+ * @param options {object}
2201
+ * @param options.keystoreId {string}
2202
+ * @param options.localId {string}
2203
+ * @param options.record {KmsKeyRecord}
2204
+ * @returns {Promise<void>}
2205
+ */
2206
+ async insertKey({
2207
+ keystoreId,
2208
+ localId,
2209
+ record
2210
+ }: {
2211
+ keystoreId: string
2212
+ localId: string
2213
+ record: KmsKeyRecord
2214
+ }): Promise<void> {
2215
+ try {
2216
+ await this._pool.query(
2217
+ `INSERT INTO kms_keys (keystore_id, local_id, record)
2218
+ VALUES ($1, $2, $3::jsonb)`,
2219
+ [keystoreId, localId, JSON.stringify(record)]
2220
+ )
2221
+ } catch (err) {
2222
+ if ((err as { code?: string }).code === '23505') {
2223
+ throw new KeyIdConflictError()
2224
+ }
2225
+ throw new StorageError({ cause: err as Error })
2226
+ }
2227
+ }
2228
+
2229
+ /**
2230
+ * @param options {object}
2231
+ * @param options.keystoreId {string}
2232
+ * @param options.localId {string}
2233
+ * @returns {Promise<KmsKeyRecord|undefined>}
2234
+ */
2235
+ async getKey({
2236
+ keystoreId,
2237
+ localId
2238
+ }: {
2239
+ keystoreId: string
2240
+ localId: string
2241
+ }): Promise<KmsKeyRecord | undefined> {
2242
+ const { rows } = await this._pool.query<{ record: KmsKeyRecord }>(
2243
+ `SELECT record FROM kms_keys
2244
+ WHERE keystore_id = $1 AND local_id = $2`,
2245
+ [keystoreId, localId]
2246
+ )
2247
+ return rows[0]?.record
2248
+ }
2249
+
2250
+ /**
2251
+ * Every stored key record under the keystore, sorted by local id (the request
2252
+ * layer caps and paginates the wire result). An empty keystore resolves an
2253
+ * empty list. The record is returned verbatim -- the at-rest cipher applies
2254
+ * above the backend.
2255
+ * @param options {object}
2256
+ * @param options.keystoreId {string}
2257
+ * @returns {Promise<Array<{ localId: string, record: KmsKeyRecord }>>}
2258
+ */
2259
+ async listKeys({
2260
+ keystoreId
2261
+ }: {
2262
+ keystoreId: string
2263
+ }): Promise<Array<{ localId: string; record: KmsKeyRecord }>> {
2264
+ const { rows } = await this._pool.query<{
2265
+ local_id: string
2266
+ record: KmsKeyRecord
2267
+ }>(
2268
+ `SELECT local_id, record FROM kms_keys
2269
+ WHERE keystore_id = $1 ORDER BY local_id`,
2270
+ [keystoreId]
2271
+ )
2272
+ return rows.map(row => ({ localId: row.local_id, record: row.record }))
2273
+ }
2274
+
2275
+ /**
2276
+ * Resolves a revocation scope to the table it lives in, the scope column
2277
+ * within that table, and the scope id value. The returned `table` and
2278
+ * `column` are server-chosen constants (never user input), so callers may
2279
+ * safely interpolate them into a SQL template; `id` remains a bound value.
2280
+ * @param scope {RevocationScope}
2281
+ * @returns {{ table: string, column: string, id: string }}
2282
+ */
2283
+ private _revocationTable(scope: RevocationScope): {
2284
+ table: string
2285
+ column: string
2286
+ id: string
2287
+ } {
2288
+ if ('keystoreId' in scope) {
2289
+ return {
2290
+ table: 'revocations',
2291
+ column: 'keystore_id',
2292
+ id: scope.keystoreId
2293
+ }
2294
+ }
2295
+ return { table: 'space_revocations', column: 'space_id', id: scope.spaceId }
2296
+ }
2297
+
2298
+ /**
2299
+ * Inserts a revocation record, create-only on
2300
+ * `(scope id, delegator, capability.id)`; a duplicate rejects with the
2301
+ * protocol's 409 (`DuplicateRevocationError`).
2302
+ * @param options {object}
2303
+ * @param options.scope {RevocationScope}
2304
+ * @param options.record {RevocationRecord}
2305
+ * @returns {Promise<void>}
2306
+ */
2307
+ async insertRevocation({
2308
+ scope,
2309
+ record
2310
+ }: {
2311
+ scope: RevocationScope
2312
+ record: RevocationRecord
2313
+ }): Promise<void> {
2314
+ // `table` / `column` are internal constants, not user input; ids are bound.
2315
+ const { table, column, id } = this._revocationTable(scope)
2316
+ try {
2317
+ // Prune rows past their GC horizon while on this (rare) write path, so
2318
+ // the hot read path (`isRevoked`, consulted on every delegated-chain
2319
+ // verification) stays a single read-only SELECT -- the SQL analogue of
2320
+ // a TTL index. Table-wide on purpose: expired rows are dead weight
2321
+ // whichever scope they belong to.
2322
+ await this._pool.query(
2323
+ `DELETE FROM ${table}
2324
+ WHERE expires IS NOT NULL AND expires <= $1`,
2325
+ [new Date().toISOString()]
2326
+ )
2327
+ await this._pool.query(
2328
+ `INSERT INTO ${table}
2329
+ (${column}, delegator, capability_id, record, expires)
2330
+ VALUES ($1, $2, $3, $4::jsonb, $5)`,
2331
+ [
2332
+ id,
2333
+ record.meta.delegator,
2334
+ record.capability.id,
2335
+ JSON.stringify(record),
2336
+ record.meta.expires ?? null
2337
+ ]
2338
+ )
2339
+ } catch (err) {
2340
+ if ((err as { code?: string }).code === '23505') {
2341
+ throw new DuplicateRevocationError()
2342
+ }
2343
+ throw new StorageError({ cause: err as Error })
2344
+ }
2345
+ }
2346
+
2347
+ /**
2348
+ * True when any of the given capabilities has a stored, unexpired
2349
+ * revocation under the scope. A single read-only SELECT: rows past their
2350
+ * `meta.expires` GC horizon are filtered out in the predicate rather than
2351
+ * pruned here -- this runs on every delegated-chain verification, so it
2352
+ * must not write; `insertRevocation` prunes on the (rare) write path
2353
+ * instead. ISO-8601 strings compare correctly under the column's byte-order
2354
+ * collation.
2355
+ * @param options {object}
2356
+ * @param options.scope {RevocationScope}
2357
+ * @param options.capabilities {CapabilitySummary[]}
2358
+ * @returns {Promise<boolean>}
2359
+ */
2360
+ async isRevoked({
2361
+ scope,
2362
+ capabilities
2363
+ }: {
2364
+ scope: RevocationScope
2365
+ capabilities: CapabilitySummary[]
2366
+ }): Promise<boolean> {
2367
+ if (capabilities.length === 0) {
2368
+ return false
2369
+ }
2370
+ // `table` / `column` are internal constants, not user input; ids are bound.
2371
+ const { table, column, id } = this._revocationTable(scope)
2372
+ const delegators = capabilities.map(entry => entry.delegator)
2373
+ const capabilityIds = capabilities.map(entry => entry.capabilityId)
2374
+ const { rows } = await this._pool.query(
2375
+ `SELECT 1 FROM ${table}
2376
+ WHERE ${column} = $1
2377
+ AND (delegator, capability_id) IN
2378
+ (SELECT * FROM unnest($2::text[], $3::text[]))
2379
+ AND (expires IS NULL OR expires > $4)
2380
+ LIMIT 1`,
2381
+ [id, delegators, capabilityIds, new Date().toISOString()]
2382
+ )
2383
+ return rows.length > 0
2384
+ }
2385
+
2386
+ // Export / import (spec "Export Space" / "Import Space")
2387
+
2388
+ /**
2389
+ * Builds the filesystem-dialect sidecar object for a resource row (the
2390
+ * `.meta.<id>.json` shape), field order matching the filesystem writer so
2391
+ * archives stay as close to byte-compatible as jsonb round-tripping allows.
2392
+ * @param row {Omit<ResourceRow, 'content'>}
2393
+ * @returns {SidecarShape}
2394
+ */
2395
+ private _sidecarFor(row: Omit<ResourceRow, 'content'>): SidecarShape {
2396
+ if (row.deleted) {
2397
+ return {
2398
+ createdAt: row.created_at,
2399
+ updatedAt: row.updated_at,
2400
+ ...(row.created_by !== null && { createdBy: row.created_by }),
2401
+ version: row.version,
2402
+ deleted: true,
2403
+ contentType: row.content_type
2404
+ }
2405
+ }
2406
+ return {
2407
+ createdAt: row.created_at,
2408
+ updatedAt: row.updated_at,
2409
+ ...(row.created_by !== null && { createdBy: row.created_by }),
2410
+ version: row.version,
2411
+ ...(row.meta_version !== null && { metaVersion: row.meta_version }),
2412
+ ...(row.custom !== null && { custom: row.custom }),
2413
+ // The client-declared key epoch (the `key-epochs` feature) rides the
2414
+ // `.meta.` sidecar so it survives an export/import round trip.
2415
+ ...(row.epoch !== null && { epoch: row.epoch })
2416
+ }
2417
+ }
2418
+
2419
+ /**
2420
+ * Exports the Space as a tar stream in the exact filesystem on-disk layout
2421
+ * (same file-name codecs, same manifest), so the archive imports into
2422
+ * either backend. Backend registration records are excluded (secret
2423
+ * material), exactly as on the filesystem.
2424
+ * @param options {object}
2425
+ * @param options.spaceId {string}
2426
+ * @returns {Promise<Readable>} tar-stream pack
2427
+ */
2428
+ async exportSpace({ spaceId }: { spaceId: string }): Promise<Readable> {
2429
+ const spaceDescription = await this.getSpaceDescription({ spaceId })
2430
+ if (!spaceDescription) {
2431
+ throw new SpaceNotFoundError({ requestName: 'Export Space' })
2432
+ }
2433
+
2434
+ const [
2435
+ { rows: policyRows },
2436
+ { rows: collectionRows },
2437
+ { rows: resourceRows },
2438
+ { rows: revocationRows }
2439
+ ] = await Promise.all([
2440
+ this._pool.query<{
2441
+ collection_id: string
2442
+ resource_id: string
2443
+ policy: PolicyDocument
2444
+ }>(
2445
+ `SELECT collection_id, resource_id, policy FROM policies
2446
+ WHERE space_id = $1`,
2447
+ [spaceId]
2448
+ ),
2449
+ this._pool.query<{
2450
+ collection_id: string
2451
+ description: CollectionDescription | null
2452
+ description_version: number
2453
+ }>(
2454
+ `SELECT collection_id, description, description_version FROM collections
2455
+ WHERE space_id = $1`,
2456
+ [spaceId]
2457
+ ),
2458
+ // Metadata only -- content bytes are fetched one resource at a time
2459
+ // while packing, so an export never holds the whole Space in memory.
2460
+ this._pool.query<
2461
+ Omit<ResourceRow, 'content'> & {
2462
+ collection_id: string
2463
+ resource_id: string
2464
+ }
2465
+ >(
2466
+ `SELECT collection_id, resource_id, content_type, is_json,
2467
+ size_bytes, version, meta_version, custom, epoch, deleted,
2468
+ created_at, updated_at, created_by
2469
+ FROM resources WHERE space_id = $1`,
2470
+ [spaceId]
2471
+ ),
2472
+ this._pool.query<{
2473
+ delegator: string
2474
+ capability_id: string
2475
+ record: RevocationRecord
2476
+ }>(
2477
+ `SELECT delegator, capability_id, record FROM space_revocations
2478
+ WHERE space_id = $1`,
2479
+ [spaceId]
2480
+ )
2481
+ ])
2482
+
2483
+ // Assemble the per-entry file lists in the filesystem's shapes: files are
2484
+ // named by the shared codecs and sorted with localeCompare, matching the
2485
+ // filesystem's directory-listing sort.
2486
+ const spacePolicy = policyRows.find(
2487
+ row => row.collection_id === '' && row.resource_id === ''
2488
+ )?.policy
2489
+
2490
+ // A file entry carries its bytes inline (the small JSON dot-files) or a
2491
+ // lazy reference to a resource representation, resolved at pack time.
2492
+ type FileEntry =
2493
+ | { name: string; bytes: Buffer }
2494
+ | { name: string; resource: { collectionId: string; resourceId: string } }
2495
+ // Space-level dot-files are always small JSON, carried inline.
2496
+ const spaceFiles: Array<{ name: string; bytes: Buffer }> = [
2497
+ {
2498
+ name: `.space.${spaceId}.json`,
2499
+ bytes: Buffer.from(JSON.stringify(spaceDescription))
2500
+ }
2501
+ ]
2502
+ if (spacePolicy) {
2503
+ spaceFiles.push({
2504
+ name: `.policy.${spaceId}.json`,
2505
+ bytes: Buffer.from(JSON.stringify(spacePolicy))
2506
+ })
2507
+ }
2508
+
2509
+ const collectionsById = new Map<string, FileEntry[]>()
2510
+ const filesFor = (collectionId: string): FileEntry[] => {
2511
+ let files = collectionsById.get(collectionId)
2512
+ if (!files) {
2513
+ files = []
2514
+ collectionsById.set(collectionId, files)
2515
+ }
2516
+ return files
2517
+ }
2518
+ for (const row of collectionRows) {
2519
+ const files = filesFor(row.collection_id)
2520
+ if (row.description !== null) {
2521
+ // Embed the description version as `_version` in the archived
2522
+ // `.collection.` file (the filesystem backend's on-disk convention) so
2523
+ // the ETag validator survives an export/import round trip and archives
2524
+ // stay interchangeable between the two backends.
2525
+ files.push({
2526
+ name: `.collection.${row.collection_id}.json`,
2527
+ bytes: Buffer.from(
2528
+ JSON.stringify({
2529
+ ...row.description,
2530
+ _version: row.description_version
2531
+ })
2532
+ )
2533
+ })
2534
+ }
2535
+ }
2536
+ for (const row of policyRows) {
2537
+ if (row.collection_id === '') {
2538
+ continue
2539
+ }
2540
+ // Collection policy keys by the collection id, resource policy by the
2541
+ // resource id -- same dot-file convention, distinct keying ids.
2542
+ const keyId = row.resource_id === '' ? row.collection_id : row.resource_id
2543
+ filesFor(row.collection_id).push({
2544
+ name: `.policy.${keyId}.json`,
2545
+ bytes: Buffer.from(JSON.stringify(row.policy))
2546
+ })
2547
+ }
2548
+ for (const row of resourceRows) {
2549
+ const files = filesFor(row.collection_id)
2550
+ files.push({
2551
+ name: `.meta.${row.resource_id}.json`,
2552
+ bytes: Buffer.from(JSON.stringify(this._sidecarFor(row)))
2553
+ })
2554
+ if (!row.deleted) {
2555
+ files.push({
2556
+ name: fileNameFor({
2557
+ resourceId: row.resource_id,
2558
+ contentType: row.content_type
2559
+ }),
2560
+ resource: {
2561
+ collectionId: row.collection_id,
2562
+ resourceId: row.resource_id
2563
+ }
2564
+ })
2565
+ }
2566
+ }
2567
+
2568
+ // Top-level order: space-level files and collection dirs interleaved,
2569
+ // sorted by name -- the same order the filesystem's readdir+sort yields.
2570
+ const topLevel: Array<
2571
+ | { kind: 'file'; name: string; bytes: Buffer }
2572
+ | { kind: 'collection'; name: string; files: FileEntry[] }
2573
+ > = [
2574
+ ...spaceFiles.map(file => ({ kind: 'file' as const, ...file })),
2575
+ ...[...collectionsById].map(([collectionId, files]) => ({
2576
+ kind: 'collection' as const,
2577
+ name: collectionId,
2578
+ files: files.sort((a, b) => a.name.localeCompare(b.name))
2579
+ }))
2580
+ ].sort((a, b) => a.name.localeCompare(b.name))
2581
+
2582
+ // Space-scoped zcap revocations travel with the export, packed under a
2583
+ // top-level `revocations/` dir and named by the shared file-name codec so
2584
+ // both backends produce the same archive entries. Pretty-printed to match
2585
+ // the filesystem's stored records.
2586
+ const revocationFiles = revocationRows
2587
+ .map(row => ({
2588
+ name: revocationFileName({
2589
+ delegator: row.delegator,
2590
+ capabilityId: row.capability_id
2591
+ }),
2592
+ bytes: Buffer.from(JSON.stringify(row.record, null, 2))
2593
+ }))
2594
+ .sort((a, b) => a.name.localeCompare(b.name))
2595
+
2596
+ const manifest = buildExportManifest({
2597
+ spaceId,
2598
+ entries: topLevel.map(entry =>
2599
+ entry.kind === 'collection'
2600
+ ? { name: entry.name, files: entry.files.map(file => file.name) }
2601
+ : { name: entry.name }
2602
+ ),
2603
+ revocationFiles: revocationFiles.map(file => file.name)
2604
+ })
2605
+
2606
+ // Fixed mtime on every entry so the archive is byte-reproducible (see
2607
+ // EXPORT_ENTRY_MTIME).
2608
+ const mtime = EXPORT_ENTRY_MTIME
2609
+ const pack = tar.pack()
2610
+ pack.entry({ name: 'manifest.yml', mtime }, YAML.stringify(manifest))
2611
+ pack.entry({ name: 'space/', type: 'directory', mtime })
2612
+ pack.entry({ name: `space/${spaceId}/`, type: 'directory', mtime })
2613
+ for (const entry of topLevel) {
2614
+ const entryTarget = `space/${spaceId}/${entry.name}`
2615
+ if (entry.kind === 'collection') {
2616
+ pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime })
2617
+ for (const file of entry.files) {
2618
+ const bytes =
2619
+ 'bytes' in file
2620
+ ? file.bytes
2621
+ : await this._resourceContent({ spaceId, ...file.resource })
2622
+ pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes)
2623
+ }
2624
+ } else {
2625
+ pack.entry({ name: entryTarget, mtime }, entry.bytes)
2626
+ }
2627
+ }
2628
+ if (revocationFiles.length > 0) {
2629
+ pack.entry({ name: 'revocations/', type: 'directory', mtime })
2630
+ for (const file of revocationFiles) {
2631
+ pack.entry({ name: `revocations/${file.name}`, mtime }, file.bytes)
2632
+ }
2633
+ }
2634
+ pack.finalize()
2635
+ return pack
2636
+ }
2637
+
2638
+ /**
2639
+ * Fetches one resource's content bytes for the export pack loop. A row
2640
+ * deleted or tombstoned between the metadata pass and this read (the export
2641
+ * is not one transaction, same as the filesystem's racy directory walk)
2642
+ * yields an empty body rather than failing the whole archive.
2643
+ * @param options {object}
2644
+ * @param options.spaceId {string}
2645
+ * @param options.collectionId {string}
2646
+ * @param options.resourceId {string}
2647
+ * @returns {Promise<Buffer>}
2648
+ */
2649
+ private async _resourceContent({
2650
+ spaceId,
2651
+ collectionId,
2652
+ resourceId
2653
+ }: {
2654
+ spaceId: string
2655
+ collectionId: string
2656
+ resourceId: string
2657
+ }): Promise<Buffer> {
2658
+ const { rows } = await this._pool.query<{ content: Buffer | null }>(
2659
+ `SELECT content FROM resources
2660
+ WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
2661
+ [spaceId, collectionId, resourceId]
2662
+ )
2663
+ return rows[0]?.content ?? Buffer.alloc(0)
2664
+ }
2665
+
2666
+ /**
2667
+ * Merges a WAS space-export tarball into an existing Space with the same
2668
+ * three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
2669
+ * conformance, cumulative 507) and skip-not-overwrite merge semantics as
2670
+ * the filesystem backend -- including tombstone carry-over and "a tombstone
2671
+ * blocks resurrection". One strict improvement: the entire apply loop runs
2672
+ * in a single transaction, so a mid-import failure leaves the Space
2673
+ * untouched atomically.
2674
+ * @param options {object}
2675
+ * @param options.spaceId {string}
2676
+ * @param options.tarStream {Readable}
2677
+ * @returns {Promise<ImportStats>}
2678
+ */
2679
+ async importSpace({
2680
+ spaceId,
2681
+ tarStream
2682
+ }: {
2683
+ spaceId: string
2684
+ tarStream: Readable
2685
+ }): Promise<ImportStats> {
2686
+ const entries = await extractTarEntries(tarStream)
2687
+ const { spacePolicy, collections, revocations } = buildImportPlan(entries)
2688
+ const {
2689
+ capacityBytes,
2690
+ maxUploadBytes,
2691
+ maxCollectionsPerSpace,
2692
+ maxResourcesPerSpace
2693
+ } = this
2694
+
2695
+ return this._withTransaction(async client => {
2696
+ await this._ensureSpaceRow({ client, spaceId })
2697
+ // Serialize with concurrent writers on this Space for the duration of
2698
+ // the import: the usage counter row is the natural lock.
2699
+ const { rows: spaceRows } = await client.query<{ usage_bytes: string }>(
2700
+ 'SELECT usage_bytes FROM spaces WHERE space_id = $1 FOR UPDATE',
2701
+ [spaceId]
2702
+ )
2703
+ const currentUsage = Number(spaceRows[0]?.usage_bytes ?? 0)
2704
+
2705
+ // One pass over the Space's Collections: description presence drives
2706
+ // both the pre-flight encryption resolution and the skip-or-create
2707
+ // decision in the apply loop (a NULL-description placeholder row counts
2708
+ // as "does not exist", like a description-less directory).
2709
+ const { rows: descriptionRows } = await client.query<{
2710
+ collection_id: string
2711
+ description: CollectionDescription | null
2712
+ }>(
2713
+ `SELECT collection_id, description FROM collections
2714
+ WHERE space_id = $1`,
2715
+ [spaceId]
2716
+ )
2717
+ const descriptionsById = new Map(
2718
+ descriptionRows.map(row => [row.collection_id, row.description])
2719
+ )
2720
+
2721
+ // Count quotas: measure the Space's existing Collection rows / live
2722
+ // Resources ONCE here, then track running totals as the apply loop
2723
+ // creates items, so an import cannot push the Space past
2724
+ // `maxCollectionsPerSpace` / `maxResourcesPerSpace`. Only brand-new items
2725
+ // count -- a re-imported existing id is skipped and does not -- mirroring
2726
+ // the per-create write-path guards without a COUNT query per row. The
2727
+ // transaction rolls the whole import back if a cap is exceeded mid-apply.
2728
+ let collectionRowCount = descriptionsById.size
2729
+ let liveResourceCount = 0
2730
+ if (maxResourcesPerSpace !== undefined) {
2731
+ const { rows: liveRows } = await client.query<{ count: number }>(
2732
+ `SELECT COUNT(*)::int AS count FROM resources
2733
+ WHERE space_id = $1 AND NOT deleted`,
2734
+ [spaceId]
2735
+ )
2736
+ liveResourceCount = liveRows[0]!.count
2737
+ }
2738
+
2739
+ // Pre-flight pass over every staged resource, before writing anything.
2740
+ // Skips (existing ids) are counted conservatively for the quota
2741
+ // estimate, as on the filesystem.
2742
+ let incomingBytes = 0
2743
+ for (const {
2744
+ collectionId,
2745
+ collectionDescription,
2746
+ resources
2747
+ } of collections) {
2748
+ const existing = descriptionsById.get(collectionId) ?? undefined
2749
+ const effectiveEncryption = existing
2750
+ ? existing.encryption
2751
+ : collectionDescription.encryption
2752
+ for (const { fileName, body } of resources) {
2753
+ if (body.length > maxUploadBytes) {
2754
+ throw new PayloadTooLargeError({
2755
+ maxUploadBytes,
2756
+ backendId: this.describe().id,
2757
+ uploadBytes: body.length
2758
+ })
2759
+ }
2760
+ if (effectiveEncryption?.scheme !== undefined) {
2761
+ const { contentType } = parseResourceFileName(fileName)
2762
+ let parsedBody: unknown
2763
+ try {
2764
+ parsedBody = JSON.parse(body.toString('utf8'))
2765
+ } catch {
2766
+ parsedBody = undefined
2767
+ }
2768
+ assertEncryptedWriteConforms({
2769
+ collectionDescription: { encryption: effectiveEncryption },
2770
+ contentType,
2771
+ body: parsedBody
2772
+ })
2773
+ }
2774
+ incomingBytes += body.length
2775
+ }
2776
+ }
2777
+ if (
2778
+ capacityBytes !== undefined &&
2779
+ currentUsage + incomingBytes > capacityBytes
2780
+ ) {
2781
+ throw new QuotaExceededError({ spaceId, capacityBytes })
2782
+ }
2783
+
2784
+ const stats: ImportStats = {
2785
+ collectionsCreated: 0,
2786
+ collectionsSkipped: 0,
2787
+ resourcesCreated: 0,
2788
+ resourcesSkipped: 0,
2789
+ policiesCreated: 0,
2790
+ policiesSkipped: 0
2791
+ }
2792
+
2793
+ // Space-level policy: restore it when the destination has none.
2794
+ if (spacePolicy) {
2795
+ const { rows } = await client.query(
2796
+ `SELECT 1 FROM policies
2797
+ WHERE space_id = $1 AND collection_id = '' AND resource_id = ''`,
2798
+ [spaceId]
2799
+ )
2800
+ if (rows.length > 0) {
2801
+ stats.policiesSkipped++
2802
+ } else {
2803
+ await this._upsertPolicy({
2804
+ queryable: client,
2805
+ spaceId,
2806
+ policy: spacePolicy
2807
+ })
2808
+ stats.policiesCreated++
2809
+ }
2810
+ }
2811
+
2812
+ let createdBytes = 0
2813
+ for (const {
2814
+ collectionId,
2815
+ collectionDescription,
2816
+ collectionPolicy,
2817
+ resources,
2818
+ resourcePolicies,
2819
+ resourceMetadata
2820
+ } of collections) {
2821
+ const collectionExisted = Boolean(descriptionsById.get(collectionId))
2822
+ if (collectionExisted) {
2823
+ stats.collectionsSkipped++
2824
+ } else {
2825
+ // A brand-new Collection row counts against the cap; upserting a
2826
+ // description onto an existing NULL-description placeholder row does
2827
+ // not add a row, so it never trips the limit.
2828
+ const isNewRow = !descriptionsById.has(collectionId)
2829
+ if (
2830
+ maxCollectionsPerSpace !== undefined &&
2831
+ isNewRow &&
2832
+ collectionRowCount >= maxCollectionsPerSpace
2833
+ ) {
2834
+ throw new CountQuotaExceededError({
2835
+ scope: 'Collections per Space',
2836
+ limit: maxCollectionsPerSpace
2837
+ })
2838
+ }
2839
+ // Import restores `createdBy` verbatim from the archived document
2840
+ // (already discarded and reapplied by `_upsertCollection`, same as
2841
+ // any other write): this is only ever a create here (the branch
2842
+ // above skips existing Collections), so there is no prior row for
2843
+ // COALESCE to prefer over it.
2844
+ await this._upsertCollection({
2845
+ queryable: client,
2846
+ spaceId,
2847
+ collectionId,
2848
+ collectionDescription,
2849
+ createdBy: collectionDescription.createdBy
2850
+ })
2851
+ if (isNewRow) {
2852
+ collectionRowCount++
2853
+ }
2854
+ descriptionsById.set(collectionId, collectionDescription)
2855
+ stats.collectionsCreated++
2856
+ }
2857
+
2858
+ // A collection-level policy travels with a newly-created collection;
2859
+ // for an existing (skipped) collection, leave its policy untouched.
2860
+ if (collectionPolicy) {
2861
+ if (collectionExisted) {
2862
+ stats.policiesSkipped++
2863
+ } else {
2864
+ await this._upsertPolicy({
2865
+ queryable: client,
2866
+ spaceId,
2867
+ collectionId,
2868
+ policy: collectionPolicy
2869
+ })
2870
+ stats.policiesCreated++
2871
+ }
2872
+ }
2873
+
2874
+ // All ids the destination already holds for this Collection -- live
2875
+ // or tombstone, either blocks the import for its id ("a tombstone
2876
+ // blocks resurrection") -- in one query instead of one per resource.
2877
+ // Created ids are added as we go, so a duplicate id later in the same
2878
+ // archive is skipped rather than tripping the primary key.
2879
+ const { rows: existingIdRows } = await client.query<{
2880
+ resource_id: string
2881
+ }>(
2882
+ `SELECT resource_id FROM resources
2883
+ WHERE space_id = $1 AND collection_id = $2`,
2884
+ [spaceId, collectionId]
2885
+ )
2886
+ const existingResourceIds = new Set(
2887
+ existingIdRows.map(row => row.resource_id)
2888
+ )
2889
+
2890
+ for (const { fileName, resourceId, body } of resources) {
2891
+ if (existingResourceIds.has(resourceId)) {
2892
+ stats.resourcesSkipped++
2893
+ // A resource-level policy travels with a newly-created resource.
2894
+ if (resourcePolicies.has(resourceId)) {
2895
+ stats.policiesSkipped++
2896
+ }
2897
+ continue
2898
+ }
2899
+ // A new live Resource counts against the per-Space cap.
2900
+ if (maxResourcesPerSpace !== undefined) {
2901
+ if (liveResourceCount >= maxResourcesPerSpace) {
2902
+ throw new CountQuotaExceededError({
2903
+ scope: 'Resources per Space',
2904
+ limit: maxResourcesPerSpace
2905
+ })
2906
+ }
2907
+ liveResourceCount++
2908
+ }
2909
+ const { contentType } = parseResourceFileName(fileName)
2910
+ await this._insertImportedResource({
2911
+ client,
2912
+ spaceId,
2913
+ collectionId,
2914
+ resourceId,
2915
+ contentType,
2916
+ body,
2917
+ sidecar: parseSidecar(resourceMetadata.get(resourceId))
2918
+ })
2919
+ existingResourceIds.add(resourceId)
2920
+ createdBytes += body.length
2921
+ stats.resourcesCreated++
2922
+
2923
+ const resourcePolicy = resourcePolicies.get(resourceId)
2924
+ if (resourcePolicy) {
2925
+ await this._upsertPolicy({
2926
+ queryable: client,
2927
+ spaceId,
2928
+ collectionId,
2929
+ resourceId,
2930
+ policy: resourcePolicy
2931
+ })
2932
+ stats.policiesCreated++
2933
+ }
2934
+ }
2935
+
2936
+ // Carry tombstones: an orphan `.meta.` sidecar that is a tombstone
2937
+ // (`deleted: true`) re-creates the tombstone row; a non-tombstone
2938
+ // orphan sidecar is anomalous and skipped. Merge semantics match
2939
+ // resources: anything the destination already has is left untouched.
2940
+ const importedResourceIds = new Set(
2941
+ resources.map(resource => resource.resourceId)
2942
+ )
2943
+ for (const resourceId of resourceMetadata.keys()) {
2944
+ if (importedResourceIds.has(resourceId)) {
2945
+ continue
2946
+ }
2947
+ const sidecar = parseSidecar(resourceMetadata.get(resourceId))
2948
+ if (sidecar?.deleted !== true) {
2949
+ continue
2950
+ }
2951
+ if (existingResourceIds.has(resourceId)) {
2952
+ stats.resourcesSkipped++
2953
+ continue
2954
+ }
2955
+ await this._insertImportedResource({
2956
+ client,
2957
+ spaceId,
2958
+ collectionId,
2959
+ resourceId,
2960
+ contentType: sidecar.contentType ?? 'application/octet-stream',
2961
+ body: null,
2962
+ sidecar
2963
+ })
2964
+ existingResourceIds.add(resourceId)
2965
+ stats.resourcesCreated++
2966
+ }
2967
+ }
2968
+
2969
+ if (createdBytes > 0) {
2970
+ // The pre-flight was conservative (it counted skips too), so the
2971
+ // actual created total always fits; apply it unguarded.
2972
+ await this._applyUsageDelta({
2973
+ client,
2974
+ spaceId,
2975
+ delta: createdBytes
2976
+ })
2977
+ }
2978
+
2979
+ // Restore the archive's Space-scoped zcap revocations under this
2980
+ // Space's scope: a capability revoked before the export must stay
2981
+ // revoked after an import (a backup/restore round-trip must not
2982
+ // resurrect revoked access). `ON CONFLICT DO NOTHING` gives the
2983
+ // skip-not-overwrite merge per record; a record past its GC horizon is
2984
+ // dropped (the capability itself has expired; `isRevoked` would prune
2985
+ // it). Transactional like the rest of the apply loop.
2986
+ const now = Date.now()
2987
+ for (const record of revocations) {
2988
+ if (record.meta.expires && Date.parse(record.meta.expires) <= now) {
2989
+ continue
2990
+ }
2991
+ await client.query(
2992
+ `INSERT INTO space_revocations
2993
+ (space_id, delegator, capability_id, record, expires)
2994
+ VALUES ($1, $2, $3, $4::jsonb, $5)
2995
+ ON CONFLICT DO NOTHING`,
2996
+ [
2997
+ spaceId,
2998
+ record.meta.delegator,
2999
+ record.capability.id,
3000
+ JSON.stringify(record),
3001
+ record.meta.expires ?? null
3002
+ ]
3003
+ )
3004
+ }
3005
+
3006
+ return stats
3007
+ })
3008
+ }
3009
+
3010
+ /**
3011
+ * Inserts one archived resource (or orphan tombstone) row for the import
3012
+ * apply loop. Timestamps, versions, `createdBy`, and `custom` come from the
3013
+ * archive's sidecar when present; an archive resource without a sidecar is
3014
+ * treated as a fresh first write on this backend (version 1, no
3015
+ * `createdBy`).
3016
+ * @param options {object}
3017
+ * @param options.client {pg.PoolClient}
3018
+ * @param options.spaceId {string}
3019
+ * @param options.collectionId {string}
3020
+ * @param options.resourceId {string}
3021
+ * @param options.contentType {string}
3022
+ * @param options.body {Buffer|null} `null` re-creates a tombstone
3023
+ * @param [options.sidecar] {SidecarShape}
3024
+ * @returns {Promise<void>}
3025
+ */
3026
+ private async _insertImportedResource({
3027
+ client,
3028
+ spaceId,
3029
+ collectionId,
3030
+ resourceId,
3031
+ contentType,
3032
+ body,
3033
+ sidecar
3034
+ }: {
3035
+ client: pg.PoolClient
3036
+ spaceId: string
3037
+ collectionId: string
3038
+ resourceId: string
3039
+ contentType: string
3040
+ body: Buffer | null
3041
+ sidecar: SidecarShape | undefined
3042
+ }): Promise<void> {
3043
+ const now = new Date().toISOString()
3044
+ const deleted = body === null
3045
+ await client.query(
3046
+ `INSERT INTO resources (
3047
+ space_id, collection_id, resource_id, content_type, content,
3048
+ is_json, size_bytes, version, meta_version, custom, deleted,
3049
+ created_at, updated_at, created_by, epoch
3050
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11,
3051
+ $12, $13, $14, $15)`,
3052
+ [
3053
+ spaceId,
3054
+ collectionId,
3055
+ resourceId,
3056
+ contentType,
3057
+ body,
3058
+ isJson({ contentType }),
3059
+ body?.length ?? 0,
3060
+ sidecar?.version ?? 1,
3061
+ sidecar?.metaVersion ?? null,
3062
+ sidecar?.custom !== undefined ? JSON.stringify(sidecar.custom) : null,
3063
+ deleted,
3064
+ sidecar?.createdAt ?? now,
3065
+ sidecar?.updatedAt ?? now,
3066
+ sidecar?.createdBy ?? null,
3067
+ // Restore the client-declared key epoch (the `key-epochs` feature) from
3068
+ // the archived sidecar; a tombstone or an unstamped Resource has none.
3069
+ sidecar?.epoch ?? null
3070
+ ]
3071
+ )
3072
+ }
3073
+ }