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