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