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.
- package/CHANGELOG.md +1432 -0
- package/LICENSE +661 -0
- package/README.md +305 -0
- package/common/css/materialize.1.0.0.min.css +13 -0
- package/dist/auth-header-hooks.d.ts +84 -0
- package/dist/auth-header-hooks.d.ts.map +1 -0
- package/dist/auth-header-hooks.js +136 -0
- package/dist/auth-header-hooks.js.map +1 -0
- package/dist/authorize.d.ts +45 -0
- package/dist/authorize.d.ts.map +1 -0
- package/dist/authorize.js +76 -0
- package/dist/authorize.js.map +1 -0
- package/dist/backends/filesystem.d.ts +1179 -0
- package/dist/backends/filesystem.d.ts.map +1 -0
- package/dist/backends/filesystem.js +2727 -0
- package/dist/backends/filesystem.js.map +1 -0
- package/dist/backends/postgres.d.ts +833 -0
- package/dist/backends/postgres.d.ts.map +1 -0
- package/dist/backends/postgres.js +2164 -0
- package/dist/backends/postgres.js.map +1 -0
- package/dist/backends/postgresSchema.d.ts +36 -0
- package/dist/backends/postgresSchema.d.ts.map +1 -0
- package/dist/backends/postgresSchema.js +203 -0
- package/dist/backends/postgresSchema.js.map +1 -0
- package/dist/config.default.d.ts +360 -0
- package/dist/config.default.d.ts.map +1 -0
- package/dist/config.default.js +493 -0
- package/dist/config.default.js.map +1 -0
- package/dist/corsProxy.d.ts +45 -0
- package/dist/corsProxy.d.ts.map +1 -0
- package/dist/corsProxy.js +278 -0
- package/dist/corsProxy.js.map +1 -0
- package/dist/digest.d.ts +45 -0
- package/dist/digest.d.ts.map +1 -0
- package/dist/digest.js +174 -0
- package/dist/digest.js.map +1 -0
- package/dist/errors.d.ts +654 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +960 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/atomicFile.d.ts +61 -0
- package/dist/lib/atomicFile.d.ts.map +1 -0
- package/dist/lib/atomicFile.js +150 -0
- package/dist/lib/atomicFile.js.map +1 -0
- package/dist/lib/backendRegistry.d.ts +59 -0
- package/dist/lib/backendRegistry.d.ts.map +1 -0
- package/dist/lib/backendRegistry.js +92 -0
- package/dist/lib/backendRegistry.js.map +1 -0
- package/dist/lib/backendUsage.d.ts +28 -0
- package/dist/lib/backendUsage.d.ts.map +1 -0
- package/dist/lib/backendUsage.js +55 -0
- package/dist/lib/backendUsage.js.map +1 -0
- package/dist/lib/backends.d.ts +142 -0
- package/dist/lib/backends.d.ts.map +1 -0
- package/dist/lib/backends.js +272 -0
- package/dist/lib/backends.js.map +1 -0
- package/dist/lib/blindedIndex.d.ts +156 -0
- package/dist/lib/blindedIndex.d.ts.map +1 -0
- package/dist/lib/blindedIndex.js +338 -0
- package/dist/lib/blindedIndex.js.map +1 -0
- package/dist/lib/collectionDescription.d.ts +33 -0
- package/dist/lib/collectionDescription.d.ts.map +1 -0
- package/dist/lib/collectionDescription.js +21 -0
- package/dist/lib/collectionDescription.js.map +1 -0
- package/dist/lib/cursor.d.ts +18 -0
- package/dist/lib/cursor.d.ts.map +1 -0
- package/dist/lib/cursor.js +50 -0
- package/dist/lib/cursor.js.map +1 -0
- package/dist/lib/edvEnvelope.d.ts +50 -0
- package/dist/lib/edvEnvelope.d.ts.map +1 -0
- package/dist/lib/edvEnvelope.js +106 -0
- package/dist/lib/edvEnvelope.js.map +1 -0
- package/dist/lib/encryption.d.ts +192 -0
- package/dist/lib/encryption.d.ts.map +1 -0
- package/dist/lib/encryption.js +383 -0
- package/dist/lib/encryption.js.map +1 -0
- package/dist/lib/etag.d.ts +33 -0
- package/dist/lib/etag.d.ts.map +1 -0
- package/dist/lib/etag.js +36 -0
- package/dist/lib/etag.js.map +1 -0
- package/dist/lib/exportManifest.d.ts +29 -0
- package/dist/lib/exportManifest.d.ts.map +1 -0
- package/dist/lib/exportManifest.js +77 -0
- package/dist/lib/exportManifest.js.map +1 -0
- package/dist/lib/importTar.d.ts +81 -0
- package/dist/lib/importTar.d.ts.map +1 -0
- package/dist/lib/importTar.js +301 -0
- package/dist/lib/importTar.js.map +1 -0
- package/dist/lib/isJson.d.ts +16 -0
- package/dist/lib/isJson.d.ts.map +1 -0
- package/dist/lib/isJson.js +17 -0
- package/dist/lib/isJson.js.map +1 -0
- package/dist/lib/keyEpoch.d.ts +38 -0
- package/dist/lib/keyEpoch.d.ts.map +1 -0
- package/dist/lib/keyEpoch.js +75 -0
- package/dist/lib/keyEpoch.js.map +1 -0
- package/dist/lib/keyedMutex.d.ts +28 -0
- package/dist/lib/keyedMutex.d.ts.map +1 -0
- package/dist/lib/keyedMutex.js +45 -0
- package/dist/lib/keyedMutex.js.map +1 -0
- package/dist/lib/kmsModule.d.ts +62 -0
- package/dist/lib/kmsModule.d.ts.map +1 -0
- package/dist/lib/kmsModule.js +327 -0
- package/dist/lib/kmsModule.js.map +1 -0
- package/dist/lib/kmsRecordCipher.d.ts +82 -0
- package/dist/lib/kmsRecordCipher.d.ts.map +1 -0
- package/dist/lib/kmsRecordCipher.js +226 -0
- package/dist/lib/kmsRecordCipher.js.map +1 -0
- package/dist/lib/pagination.d.ts +19 -0
- package/dist/lib/pagination.d.ts.map +1 -0
- package/dist/lib/pagination.js +21 -0
- package/dist/lib/pagination.js.map +1 -0
- package/dist/lib/paths.d.ts +273 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +270 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/preconditions.d.ts +65 -0
- package/dist/lib/preconditions.d.ts.map +1 -0
- package/dist/lib/preconditions.js +112 -0
- package/dist/lib/preconditions.js.map +1 -0
- package/dist/lib/resourceFileName.d.ts +41 -0
- package/dist/lib/resourceFileName.d.ts.map +1 -0
- package/dist/lib/resourceFileName.js +58 -0
- package/dist/lib/resourceFileName.js.map +1 -0
- package/dist/lib/revocations.d.ts +49 -0
- package/dist/lib/revocations.d.ts.map +1 -0
- package/dist/lib/revocations.js +86 -0
- package/dist/lib/revocations.js.map +1 -0
- package/dist/lib/validateDid.d.ts +19 -0
- package/dist/lib/validateDid.d.ts.map +1 -0
- package/dist/lib/validateDid.js +35 -0
- package/dist/lib/validateDid.js.map +1 -0
- package/dist/lib/validateId.d.ts +66 -0
- package/dist/lib/validateId.d.ts.map +1 -0
- package/dist/lib/validateId.js +126 -0
- package/dist/lib/validateId.js.map +1 -0
- package/dist/plugin.d.ts +115 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +134 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +78 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +115 -0
- package/dist/policy.js.map +1 -0
- package/dist/provisioning.d.ts +38 -0
- package/dist/provisioning.d.ts.map +1 -0
- package/dist/provisioning.js +72 -0
- package/dist/provisioning.js.map +1 -0
- package/dist/requests/BackendRequest.d.ts +61 -0
- package/dist/requests/BackendRequest.d.ts.map +1 -0
- package/dist/requests/BackendRequest.js +152 -0
- package/dist/requests/BackendRequest.js.map +1 -0
- package/dist/requests/CollectionRequest.d.ts +234 -0
- package/dist/requests/CollectionRequest.d.ts.map +1 -0
- package/dist/requests/CollectionRequest.js +705 -0
- package/dist/requests/CollectionRequest.js.map +1 -0
- package/dist/requests/KeyRequest.d.ts +115 -0
- package/dist/requests/KeyRequest.d.ts.map +1 -0
- package/dist/requests/KeyRequest.js +468 -0
- package/dist/requests/KeyRequest.js.map +1 -0
- package/dist/requests/KeystoreRequest.d.ts +99 -0
- package/dist/requests/KeystoreRequest.d.ts.map +1 -0
- package/dist/requests/KeystoreRequest.js +247 -0
- package/dist/requests/KeystoreRequest.js.map +1 -0
- package/dist/requests/PolicyRequest.d.ts +53 -0
- package/dist/requests/PolicyRequest.d.ts.map +1 -0
- package/dist/requests/PolicyRequest.js +124 -0
- package/dist/requests/PolicyRequest.js.map +1 -0
- package/dist/requests/ResourceRequest.d.ts +134 -0
- package/dist/requests/ResourceRequest.d.ts.map +1 -0
- package/dist/requests/ResourceRequest.js +548 -0
- package/dist/requests/ResourceRequest.js.map +1 -0
- package/dist/requests/RevocationRequest.d.ts +67 -0
- package/dist/requests/RevocationRequest.d.ts.map +1 -0
- package/dist/requests/RevocationRequest.js +187 -0
- package/dist/requests/RevocationRequest.js.map +1 -0
- package/dist/requests/SpaceRequest.d.ts +204 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -0
- package/dist/requests/SpaceRequest.js +520 -0
- package/dist/requests/SpaceRequest.js.map +1 -0
- package/dist/requests/SpacesRepositoryRequest.d.ts +50 -0
- package/dist/requests/SpacesRepositoryRequest.d.ts.map +1 -0
- package/dist/requests/SpacesRepositoryRequest.js +159 -0
- package/dist/requests/SpacesRepositoryRequest.js.map +1 -0
- package/dist/requests/collectionContext.d.ts +21 -0
- package/dist/requests/collectionContext.d.ts.map +1 -0
- package/dist/requests/collectionContext.js +30 -0
- package/dist/requests/collectionContext.js.map +1 -0
- package/dist/requests/controllerConsent.d.ts +58 -0
- package/dist/requests/controllerConsent.d.ts.map +1 -0
- package/dist/requests/controllerConsent.js +73 -0
- package/dist/requests/controllerConsent.js.map +1 -0
- package/dist/requests/keystoreContext.d.ts +68 -0
- package/dist/requests/keystoreContext.d.ts.map +1 -0
- package/dist/requests/keystoreContext.js +87 -0
- package/dist/requests/keystoreContext.js.map +1 -0
- package/dist/requests/resourceInput.d.ts +19 -0
- package/dist/requests/resourceInput.d.ts.map +1 -0
- package/dist/requests/resourceInput.js +102 -0
- package/dist/requests/resourceInput.js.map +1 -0
- package/dist/requests/spaceContext.d.ts +127 -0
- package/dist/requests/spaceContext.d.ts.map +1 -0
- package/dist/requests/spaceContext.js +201 -0
- package/dist/requests/spaceContext.js.map +1 -0
- package/dist/routes.d.ts +55 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +306 -0
- package/dist/routes.js.map +1 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +60 -0
- package/dist/server.js.map +1 -0
- package/dist/start.d.ts +7 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +61 -0
- package/dist/start.js.map +1 -0
- package/dist/storage.d.ts +29 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +47 -0
- package/dist/storage.js.map +1 -0
- package/dist/types.d.ts +915 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/views/home.hbs +9 -0
- package/dist/views/templates/main.hbs +12 -0
- package/dist/zcap.d.ts +267 -0
- package/dist/zcap.d.ts.map +1 -0
- package/dist/zcap.js +478 -0
- package/dist/zcap.js.map +1 -0
- package/package.json +109 -0
- package/src/auth-header-hooks.ts +163 -0
- package/src/authorize.ts +117 -0
- package/src/backends/filesystem.ts +3626 -0
- package/src/backends/postgres.ts +3073 -0
- package/src/backends/postgresSchema.ts +231 -0
- package/src/config.default.ts +657 -0
- package/src/corsProxy.ts +326 -0
- package/src/declarations.d.ts +28 -0
- package/src/digest.ts +223 -0
- package/src/errors.ts +1122 -0
- package/src/index.ts +21 -0
- package/src/lib/atomicFile.ts +176 -0
- package/src/lib/backendRegistry.ts +139 -0
- package/src/lib/backendUsage.ts +79 -0
- package/src/lib/backends.ts +351 -0
- package/src/lib/blindedIndex.ts +466 -0
- package/src/lib/collectionDescription.ts +43 -0
- package/src/lib/cursor.ts +52 -0
- package/src/lib/edvEnvelope.ts +120 -0
- package/src/lib/encryption.ts +472 -0
- package/src/lib/etag.ts +40 -0
- package/src/lib/exportManifest.ts +115 -0
- package/src/lib/importTar.ts +381 -0
- package/src/lib/isJson.ts +18 -0
- package/src/lib/keyEpoch.ts +89 -0
- package/src/lib/keyedMutex.ts +45 -0
- package/src/lib/kmsModule.ts +451 -0
- package/src/lib/kmsRecordCipher.ts +292 -0
- package/src/lib/pagination.ts +22 -0
- package/src/lib/paths.ts +382 -0
- package/src/lib/preconditions.ts +145 -0
- package/src/lib/resourceFileName.ts +69 -0
- package/src/lib/revocations.ts +116 -0
- package/src/lib/validateDid.ts +41 -0
- package/src/lib/validateId.ts +148 -0
- package/src/plugin.ts +288 -0
- package/src/policy.ts +176 -0
- package/src/provisioning.ts +98 -0
- package/src/requests/BackendRequest.ts +219 -0
- package/src/requests/CollectionRequest.ts +899 -0
- package/src/requests/KeyRequest.ts +617 -0
- package/src/requests/KeystoreRequest.ts +325 -0
- package/src/requests/PolicyRequest.ts +169 -0
- package/src/requests/ResourceRequest.ts +649 -0
- package/src/requests/RevocationRequest.ts +267 -0
- package/src/requests/SpaceRequest.ts +686 -0
- package/src/requests/SpacesRepositoryRequest.ts +194 -0
- package/src/requests/collectionContext.ts +41 -0
- package/src/requests/controllerConsent.ts +108 -0
- package/src/requests/keystoreContext.ts +124 -0
- package/src/requests/resourceInput.ts +118 -0
- package/src/requests/spaceContext.ts +311 -0
- package/src/routes.ts +420 -0
- package/src/server.ts +69 -0
- package/src/start.ts +71 -0
- package/src/storage.ts +60 -0
- package/src/types.ts +1016 -0
- package/src/views/home.hbs +9 -0
- package/src/views/templates/main.hbs +12 -0
- 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
|
+
}
|