was-teaching-server 0.9.1 → 0.10.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 +50 -1
- package/dist/backends/filesystem.d.ts +161 -2
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +491 -60
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +166 -0
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +625 -29
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +25 -0
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -1
- 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/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/routes.d.ts.map +1 -1
- package/dist/routes.js +21 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +81 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/backends/filesystem.ts +638 -57
- package/src/backends/postgres.ts +867 -21
- package/src/backends/postgresSchema.ts +69 -57
- package/src/errors.ts +19 -0
- package/src/lib/importTar.ts +107 -4
- package/src/lib/paths.ts +48 -0
- package/src/lib/resourceFileName.ts +69 -0
- package/src/requests/ChunkRequest.ts +478 -0
- package/src/routes.ts +46 -0
- package/src/types.ts +78 -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'
|
|
@@ -373,8 +375,9 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
373
375
|
* It advertises the `conditional-writes` affordance: it exposes a per-Resource
|
|
374
376
|
* `version` as an HTTP `ETag` validator and honors `If-Match` / `If-None-Match`
|
|
375
377
|
* write preconditions atomically (returning `412 precondition-failed` on a
|
|
376
|
-
* mismatch).
|
|
377
|
-
*
|
|
378
|
+
* mismatch). It also advertises `chunked-streams`: chunk addressing for large
|
|
379
|
+
* Resources (`/{resourceId}/chunks/{n}`), each chunk stored opaquely like a
|
|
380
|
+
* binary Resource representation.
|
|
378
381
|
* (Client-side encryption is deliberately not a backend feature: encrypted
|
|
379
382
|
* documents are opaque client-encrypted JSON this backend already stores
|
|
380
383
|
* faithfully, with no server cooperation.)
|
|
@@ -399,11 +402,14 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
399
402
|
// `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
|
|
400
403
|
// keys on the `encryption` marker, a client-declared `epoch` stamp on
|
|
401
404
|
// Resources, and conditional (`If-Match`) Collection Description writes.
|
|
405
|
+
// `chunked-streams`: chunk addressing (`/{resourceId}/chunks/{n}`) for a
|
|
406
|
+
// large Resource, each chunk stored opaquely (bytes + content-type).
|
|
402
407
|
features: [
|
|
403
408
|
'conditional-writes',
|
|
404
409
|
'changes-query',
|
|
405
410
|
'blinded-index-query',
|
|
406
|
-
'key-epochs'
|
|
411
|
+
'key-epochs',
|
|
412
|
+
'chunked-streams'
|
|
407
413
|
]
|
|
408
414
|
}
|
|
409
415
|
}
|
|
@@ -1096,6 +1102,14 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1096
1102
|
spaceEntries.sort((a, b) => a.name.localeCompare(b.name))
|
|
1097
1103
|
|
|
1098
1104
|
const collectionEntriesByDir: Record<string, typeof spaceEntries> = {}
|
|
1105
|
+
// Per-Resource chunk directories (`.chunks.<encId>/`; the `chunked-streams`
|
|
1106
|
+
// feature) are subdirectories of a Collection dir, so the file filter below
|
|
1107
|
+
// skips them. Gather each one's files here so a chunked Resource's chunks
|
|
1108
|
+
// travel in the export, keyed by Collection then by chunk-directory name.
|
|
1109
|
+
const chunkDirsByCollection: Record<
|
|
1110
|
+
string,
|
|
1111
|
+
Array<{ dirName: string; files: string[] }>
|
|
1112
|
+
> = {}
|
|
1099
1113
|
for (const entry of spaceEntries) {
|
|
1100
1114
|
if (!entry.isDirectory()) {
|
|
1101
1115
|
continue
|
|
@@ -1107,6 +1121,22 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1107
1121
|
collectionEntriesByDir[entry.name] = entries
|
|
1108
1122
|
.filter(e => e.isFile())
|
|
1109
1123
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1124
|
+
const chunkDirs: Array<{ dirName: string; files: string[] }> = []
|
|
1125
|
+
for (const sub of entries
|
|
1126
|
+
.filter(e => e.isDirectory() && e.name.startsWith(CHUNK_DIR_PREFIX))
|
|
1127
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1128
|
+
const files = (
|
|
1129
|
+
await fs.promises.readdir(
|
|
1130
|
+
path.join(sourceSpaceDir, entry.name, sub.name),
|
|
1131
|
+
{ withFileTypes: true }
|
|
1132
|
+
)
|
|
1133
|
+
)
|
|
1134
|
+
.filter(e => e.isFile())
|
|
1135
|
+
.map(e => e.name)
|
|
1136
|
+
.sort((a, b) => a.localeCompare(b))
|
|
1137
|
+
chunkDirs.push({ dirName: sub.name, files })
|
|
1138
|
+
}
|
|
1139
|
+
chunkDirsByCollection[entry.name] = chunkDirs
|
|
1110
1140
|
}
|
|
1111
1141
|
|
|
1112
1142
|
// Space-scoped zcap revocations travel with the export. They live in the
|
|
@@ -1135,9 +1165,18 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1135
1165
|
entry.isDirectory()
|
|
1136
1166
|
? {
|
|
1137
1167
|
name: entry.name,
|
|
1138
|
-
files
|
|
1139
|
-
|
|
1140
|
-
)
|
|
1168
|
+
// The Collection's own files, then each chunked Resource's chunk
|
|
1169
|
+
// files listed by their `.chunks.<encId>/<file>` relative path
|
|
1170
|
+
// (the manifest mirrors the pack order below).
|
|
1171
|
+
files: [
|
|
1172
|
+
...(collectionEntriesByDir[entry.name] ?? []).map(
|
|
1173
|
+
file => file.name
|
|
1174
|
+
),
|
|
1175
|
+
...(chunkDirsByCollection[entry.name] ?? []).flatMap(
|
|
1176
|
+
({ dirName, files }) =>
|
|
1177
|
+
files.map(file => `${dirName}/${file}`)
|
|
1178
|
+
)
|
|
1179
|
+
]
|
|
1141
1180
|
}
|
|
1142
1181
|
: // top-level files in space (e.g. .space.<spaceId>.json)
|
|
1143
1182
|
{ name: entry.name }
|
|
@@ -1164,6 +1203,25 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1164
1203
|
)
|
|
1165
1204
|
pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes)
|
|
1166
1205
|
}
|
|
1206
|
+
// A chunked Resource's chunk directory and its files, packed under the
|
|
1207
|
+
// Collection so `.chunks.<encId>/<file>` round-trips on import.
|
|
1208
|
+
for (const { dirName, files } of chunkDirsByCollection[entry.name] ??
|
|
1209
|
+
[]) {
|
|
1210
|
+
pack.entry({
|
|
1211
|
+
name: `${entryTarget}/${dirName}/`,
|
|
1212
|
+
type: 'directory',
|
|
1213
|
+
mtime
|
|
1214
|
+
})
|
|
1215
|
+
for (const file of files) {
|
|
1216
|
+
const bytes = await fs.promises.readFile(
|
|
1217
|
+
path.join(sourceSpaceDir, entry.name, dirName, file)
|
|
1218
|
+
)
|
|
1219
|
+
pack.entry(
|
|
1220
|
+
{ name: `${entryTarget}/${dirName}/${file}`, mtime },
|
|
1221
|
+
bytes
|
|
1222
|
+
)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1167
1225
|
} else if (entry.isFile()) {
|
|
1168
1226
|
const bytes = await fs.promises.readFile(
|
|
1169
1227
|
path.join(sourceSpaceDir, entry.name)
|
|
@@ -1226,7 +1284,8 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1226
1284
|
for (const {
|
|
1227
1285
|
collectionId,
|
|
1228
1286
|
collectionDescription,
|
|
1229
|
-
resources
|
|
1287
|
+
resources,
|
|
1288
|
+
chunkFiles
|
|
1230
1289
|
} of collections) {
|
|
1231
1290
|
const existing = await this.getCollectionDescription({
|
|
1232
1291
|
spaceId,
|
|
@@ -1259,6 +1318,19 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1259
1318
|
}
|
|
1260
1319
|
incomingBytes += body.length
|
|
1261
1320
|
}
|
|
1321
|
+
// Chunk files (the `chunked-streams` feature) inherit the per-upload cap
|
|
1322
|
+
// and quota estimate, but NOT the encryption-conformance check: a chunk is
|
|
1323
|
+
// opaque bytes, not a JSON envelope of the Collection's scheme.
|
|
1324
|
+
for (const { body } of chunkFiles) {
|
|
1325
|
+
if (maxUploadBytes !== undefined && body.length > maxUploadBytes) {
|
|
1326
|
+
throw new PayloadTooLargeError({
|
|
1327
|
+
maxUploadBytes,
|
|
1328
|
+
backendId: this.describe().id,
|
|
1329
|
+
uploadBytes: body.length
|
|
1330
|
+
})
|
|
1331
|
+
}
|
|
1332
|
+
incomingBytes += body.length
|
|
1333
|
+
}
|
|
1262
1334
|
}
|
|
1263
1335
|
if (capacityBytes !== undefined) {
|
|
1264
1336
|
await this._assertSpaceHeadroom({
|
|
@@ -1309,7 +1381,8 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1309
1381
|
collectionPolicy,
|
|
1310
1382
|
resources,
|
|
1311
1383
|
resourcePolicies,
|
|
1312
|
-
resourceMetadata
|
|
1384
|
+
resourceMetadata,
|
|
1385
|
+
chunkFiles
|
|
1313
1386
|
} of collections) {
|
|
1314
1387
|
// check if collection already exists
|
|
1315
1388
|
const collectionExisted = Boolean(
|
|
@@ -1451,6 +1524,43 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
1451
1524
|
})
|
|
1452
1525
|
stats.resourcesCreated++
|
|
1453
1526
|
}
|
|
1527
|
+
|
|
1528
|
+
// Restore chunk files of chunked Resources (the `chunked-streams` feature)
|
|
1529
|
+
// into their per-Resource chunk directories. Skip-not-overwrite, per chunk
|
|
1530
|
+
// file: an existing chunk file is left untouched, so a re-import never
|
|
1531
|
+
// clobbers stored chunk bytes (or their version sidecar). An ORPHAN chunk
|
|
1532
|
+
// file -- one whose parent Resource is absent or a tombstone (no live
|
|
1533
|
+
// representation on the destination after the Resource apply loop above)
|
|
1534
|
+
// -- is skipped rather than resurrected, matching the live write path's
|
|
1535
|
+
// parent-exists rule (and the Postgres import).
|
|
1536
|
+
const parentIsLive = new Map<string, boolean>()
|
|
1537
|
+
for (const { resourceId, fileName, body } of chunkFiles) {
|
|
1538
|
+
let live = parentIsLive.get(resourceId)
|
|
1539
|
+
if (live === undefined) {
|
|
1540
|
+
live = Boolean(await this._findFile({ collectionDir, resourceId }))
|
|
1541
|
+
parentIsLive.set(resourceId, live)
|
|
1542
|
+
}
|
|
1543
|
+
if (!live) {
|
|
1544
|
+
continue
|
|
1545
|
+
}
|
|
1546
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
1547
|
+
const target = path.join(chunkDir, fileName)
|
|
1548
|
+
this._assertContained(target)
|
|
1549
|
+
let present = false
|
|
1550
|
+
try {
|
|
1551
|
+
await fsStat(target)
|
|
1552
|
+
present = true
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
1555
|
+
throw err
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (present) {
|
|
1559
|
+
continue
|
|
1560
|
+
}
|
|
1561
|
+
await mkdir(chunkDir, { recursive: true })
|
|
1562
|
+
await atomicWriteFile({ filePath: target, data: body })
|
|
1563
|
+
}
|
|
1454
1564
|
}
|
|
1455
1565
|
|
|
1456
1566
|
// Restore the archive's Space-scoped zcap revocations under this Space's
|
|
@@ -2009,6 +2119,83 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2009
2119
|
}
|
|
2010
2120
|
}
|
|
2011
2121
|
|
|
2122
|
+
await this._writeRepresentationBytes({ spaceId, filePath, input })
|
|
2123
|
+
|
|
2124
|
+
// A Resource has a single current representation: remove any prior
|
|
2125
|
+
// representation stored under a different content-type (its filename
|
|
2126
|
+
// differs). Write-new-then-prune (not delete-then-write) so the resource is
|
|
2127
|
+
// never momentarily absent.
|
|
2128
|
+
const existing = await this._resourceFilesFor({ collectionDir, resourceId })
|
|
2129
|
+
await Promise.all(
|
|
2130
|
+
existing
|
|
2131
|
+
.filter(name => path.resolve(name) !== path.resolve(filePath))
|
|
2132
|
+
.map(name => rm(name))
|
|
2133
|
+
)
|
|
2134
|
+
|
|
2135
|
+
// Maintain the server-managed timestamps and the monotonic `version`: a
|
|
2136
|
+
// content write sets `createdAt` on first write, bumps `updatedAt`, and
|
|
2137
|
+
// increments `version` (the ETag validator) from its prior value, preserving
|
|
2138
|
+
// any user-writable `custom` and the independent `metaVersion` already stored
|
|
2139
|
+
// in the sidecar (a content write does not touch the metadata sub-resource).
|
|
2140
|
+
//
|
|
2141
|
+
// `createdBy` pairs with `createdAt`: taken from this write's invoker only
|
|
2142
|
+
// when this write creates the sidecar, so it names the creator rather than
|
|
2143
|
+
// the last writer, and is preserved verbatim afterward -- including
|
|
2144
|
+
// preserved-as-absent, so a Resource created with no invoker never has a
|
|
2145
|
+
// later writer backfilled into it. A tombstone keeps both, so re-creating a
|
|
2146
|
+
// deleted id under a different invoker preserves the original creator, as
|
|
2147
|
+
// it does the original `createdAt`.
|
|
2148
|
+
const now = new Date().toISOString()
|
|
2149
|
+
const prior = await this._readMetaSidecar({ collectionDir, resourceId })
|
|
2150
|
+
const version = (prior?.version ?? 0) + 1
|
|
2151
|
+
const creator = prior ? prior.createdBy : createdBy
|
|
2152
|
+
await this._writeMetaSidecar({
|
|
2153
|
+
collectionDir,
|
|
2154
|
+
resourceId,
|
|
2155
|
+
sidecar: {
|
|
2156
|
+
createdAt: prior?.createdAt ?? now,
|
|
2157
|
+
updatedAt: now,
|
|
2158
|
+
...(creator !== undefined && { createdBy: creator }),
|
|
2159
|
+
version,
|
|
2160
|
+
...(prior?.metaVersion !== undefined && {
|
|
2161
|
+
metaVersion: prior.metaVersion
|
|
2162
|
+
}),
|
|
2163
|
+
...(prior?.custom && { custom: prior.custom }),
|
|
2164
|
+
// The key-epoch stamp is set from this write's declaration and CLEARED
|
|
2165
|
+
// when absent (the new ciphertext's epoch is unknown -- a stale stamp is
|
|
2166
|
+
// worse than none), so it is NOT preserved from `prior` like `custom`.
|
|
2167
|
+
...(epoch !== undefined && { epoch })
|
|
2168
|
+
}
|
|
2169
|
+
})
|
|
2170
|
+
return { version }
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* Writes a representation body (JSON value or byte stream) to `filePath`,
|
|
2175
|
+
* applying the same size guards every write path shares: the per-upload cap
|
|
2176
|
+
* (413 `PayloadTooLargeError`) and, when a byte quota is configured, the
|
|
2177
|
+
* Space headroom (507). A JSON body is fully in memory, so its size is checked
|
|
2178
|
+
* up front and written atomically; a binary body is streamed through the cap /
|
|
2179
|
+
* quota guards into a temp file and durably committed (fsync + rename + dir
|
|
2180
|
+
* fsync), removing the partial file on any failure. Shared by the Resource
|
|
2181
|
+
* write path (`_writeResourceLocked`) and the chunk write path
|
|
2182
|
+
* (`_writeChunkLocked`); the caller has already resolved `filePath` and ensured
|
|
2183
|
+
* its parent directory exists for the streamed case.
|
|
2184
|
+
* @param options {object}
|
|
2185
|
+
* @param options.spaceId {string}
|
|
2186
|
+
* @param options.filePath {string} absolute path of the representation file
|
|
2187
|
+
* @param options.input {ResourceInput}
|
|
2188
|
+
* @returns {Promise<void>}
|
|
2189
|
+
*/
|
|
2190
|
+
private async _writeRepresentationBytes({
|
|
2191
|
+
spaceId,
|
|
2192
|
+
filePath,
|
|
2193
|
+
input
|
|
2194
|
+
}: {
|
|
2195
|
+
spaceId: string
|
|
2196
|
+
filePath: string
|
|
2197
|
+
input: ResourceInput
|
|
2198
|
+
}): Promise<void> {
|
|
2012
2199
|
const { capacityBytes, maxUploadBytes } = this
|
|
2013
2200
|
|
|
2014
2201
|
if (input.kind === 'json') {
|
|
@@ -2098,54 +2285,6 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2098
2285
|
throw err
|
|
2099
2286
|
}
|
|
2100
2287
|
}
|
|
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
2288
|
}
|
|
2150
2289
|
|
|
2151
2290
|
/**
|
|
@@ -2598,6 +2737,15 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2598
2737
|
)
|
|
2599
2738
|
// Drop the content representation(s) but KEEP the sidecar as the tombstone.
|
|
2600
2739
|
await Promise.all(filesForResource.map(filename => rm(filename)))
|
|
2740
|
+
// Cascade-delete the Resource's chunks (the `chunked-streams` feature): a
|
|
2741
|
+
// chunk must never outlive its parent Resource, so its whole chunk
|
|
2742
|
+
// directory goes with the content. Runs under the same per-Resource lock a
|
|
2743
|
+
// `writeChunk` takes, so a chunk write racing this delete cannot re-create
|
|
2744
|
+
// an orphan directory. `force` makes an absent chunk directory a no-op.
|
|
2745
|
+
await rm(this._chunkDir({ collectionDir, resourceId }), {
|
|
2746
|
+
recursive: true,
|
|
2747
|
+
force: true
|
|
2748
|
+
})
|
|
2601
2749
|
// Bump `version` / `updatedAt` so the tombstone sorts after the Resource's
|
|
2602
2750
|
// prior state in the change feed, and continues the monotonic version (a
|
|
2603
2751
|
// later re-create reads this sidecar and keeps counting up). `custom` is
|
|
@@ -2630,6 +2778,439 @@ export class FileSystemBackend implements StorageBackend {
|
|
|
2630
2778
|
)
|
|
2631
2779
|
}
|
|
2632
2780
|
|
|
2781
|
+
// Chunks (the `chunked-streams` feature)
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* Builds the on-disk path for a Resource's chunk directory
|
|
2785
|
+
* (`.chunks.<encodedResourceId>/`) inside its Collection dir. A hidden
|
|
2786
|
+
* subdirectory (leading `.`, so it is invisible to the `r.`-prefixed Collection
|
|
2787
|
+
* listing and the live-Resource count) that holds the Resource's chunk
|
|
2788
|
+
* representations; inside it a chunk is stored exactly like a Resource keyed by
|
|
2789
|
+
* its index (`r.<index>.<encodedContentType>.<ext>` plus a `.meta.<index>.json`
|
|
2790
|
+
* version sidecar), so the Resource file / sidecar helpers are reused verbatim
|
|
2791
|
+
* with the chunk directory as the `collectionDir`.
|
|
2792
|
+
* @param options {object}
|
|
2793
|
+
* @param options.collectionDir {string}
|
|
2794
|
+
* @param options.resourceId {string}
|
|
2795
|
+
* @returns {string}
|
|
2796
|
+
*/
|
|
2797
|
+
_chunkDir({
|
|
2798
|
+
collectionDir,
|
|
2799
|
+
resourceId
|
|
2800
|
+
}: {
|
|
2801
|
+
collectionDir: string
|
|
2802
|
+
resourceId: string
|
|
2803
|
+
}): string {
|
|
2804
|
+
const chunkDir = path.join(collectionDir, chunkDirName(resourceId))
|
|
2805
|
+
this._assertContained(chunkDir)
|
|
2806
|
+
return chunkDir
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/**
|
|
2810
|
+
* Writes one chunk of a chunked Resource, keyed by
|
|
2811
|
+
* `(spaceId, collectionId, resourceId, chunkIndex)`. The parent Resource MUST
|
|
2812
|
+
* already exist (else `ResourceNotFoundError`, 404), checked under the same
|
|
2813
|
+
* per-Resource lock a `deleteResource` cascade takes so a chunk can never be
|
|
2814
|
+
* orphaned by a racing delete. The body is stored opaquely (bytes +
|
|
2815
|
+
* content-type) through the shared upload-cap / quota guards, and the chunk's
|
|
2816
|
+
* own monotonic `version` (its ETag validator, independent of the parent's) is
|
|
2817
|
+
* bumped; any `If-Match` / `If-None-Match` precondition is evaluated on that
|
|
2818
|
+
* version atomically with the write (`PreconditionFailedError`, 412).
|
|
2819
|
+
* @param options {object}
|
|
2820
|
+
* @param options.spaceId {string}
|
|
2821
|
+
* @param options.collectionId {string}
|
|
2822
|
+
* @param options.resourceId {string}
|
|
2823
|
+
* @param options.chunkIndex {number} non-negative integer chunk position
|
|
2824
|
+
* @param options.input {ResourceInput}
|
|
2825
|
+
* @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
|
|
2826
|
+
* @param [options.ifNoneMatch] {boolean} `If-None-Match: *` (create-if-absent)
|
|
2827
|
+
* @returns {Promise<{ version: number }>} the chunk's new version
|
|
2828
|
+
*/
|
|
2829
|
+
async writeChunk({
|
|
2830
|
+
spaceId,
|
|
2831
|
+
collectionId,
|
|
2832
|
+
resourceId,
|
|
2833
|
+
chunkIndex,
|
|
2834
|
+
input,
|
|
2835
|
+
ifMatch,
|
|
2836
|
+
ifNoneMatch
|
|
2837
|
+
}: {
|
|
2838
|
+
spaceId: string
|
|
2839
|
+
collectionId: string
|
|
2840
|
+
resourceId: string
|
|
2841
|
+
chunkIndex: number
|
|
2842
|
+
input: ResourceInput
|
|
2843
|
+
ifMatch?: string
|
|
2844
|
+
ifNoneMatch?: boolean
|
|
2845
|
+
}): Promise<{ version: number }> {
|
|
2846
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
2847
|
+
// Serialize on the parent Resource's lock key -- the same key
|
|
2848
|
+
// `deleteResource` takes -- so the parent-exists check, the write, and the
|
|
2849
|
+
// cascade delete cannot interleave (no orphan chunk).
|
|
2850
|
+
return this._writeMutex.run(
|
|
2851
|
+
this._resourceLockKey({ spaceId, collectionId, resourceId }),
|
|
2852
|
+
() =>
|
|
2853
|
+
this._writeChunkLocked({
|
|
2854
|
+
spaceId,
|
|
2855
|
+
collectionDir,
|
|
2856
|
+
resourceId,
|
|
2857
|
+
chunkIndex,
|
|
2858
|
+
input,
|
|
2859
|
+
ifMatch,
|
|
2860
|
+
ifNoneMatch
|
|
2861
|
+
})
|
|
2862
|
+
)
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
/**
|
|
2866
|
+
* The critical section of `writeChunk`, run under the per-Resource lock. See
|
|
2867
|
+
* `writeChunk` for the parameters.
|
|
2868
|
+
* @returns {Promise<{ version: number }>}
|
|
2869
|
+
*/
|
|
2870
|
+
private async _writeChunkLocked({
|
|
2871
|
+
spaceId,
|
|
2872
|
+
collectionDir,
|
|
2873
|
+
resourceId,
|
|
2874
|
+
chunkIndex,
|
|
2875
|
+
input,
|
|
2876
|
+
ifMatch,
|
|
2877
|
+
ifNoneMatch
|
|
2878
|
+
}: {
|
|
2879
|
+
spaceId: string
|
|
2880
|
+
collectionDir: string
|
|
2881
|
+
resourceId: string
|
|
2882
|
+
chunkIndex: number
|
|
2883
|
+
input: ResourceInput
|
|
2884
|
+
ifMatch?: string
|
|
2885
|
+
ifNoneMatch?: boolean
|
|
2886
|
+
}): Promise<{ version: number }> {
|
|
2887
|
+
// The parent Resource must exist: writing a chunk of an absent Resource
|
|
2888
|
+
// rejects, so orphan chunks cannot accumulate.
|
|
2889
|
+
const parentExists =
|
|
2890
|
+
(await this._findFile({ collectionDir, resourceId })) !== undefined
|
|
2891
|
+
if (!parentExists) {
|
|
2892
|
+
throw new ResourceNotFoundError({ requestName: 'Write Chunk' })
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
// Inside the chunk directory a chunk is a Resource keyed by its index, so the
|
|
2896
|
+
// Resource file / sidecar helpers apply with `chunkDir` as the collectionDir
|
|
2897
|
+
// and the stringified index as the resourceId.
|
|
2898
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
2899
|
+
const chunkId = String(chunkIndex)
|
|
2900
|
+
const filename = fileNameFor({
|
|
2901
|
+
resourceId: chunkId,
|
|
2902
|
+
contentType: input.contentType
|
|
2903
|
+
})
|
|
2904
|
+
const filePath = path.join(chunkDir, filename)
|
|
2905
|
+
this._assertContained(filePath)
|
|
2906
|
+
|
|
2907
|
+
// Evaluate any precondition against the chunk's current version before
|
|
2908
|
+
// writing (still inside the lock, so check and write are atomic).
|
|
2909
|
+
if (ifMatch !== undefined || ifNoneMatch) {
|
|
2910
|
+
await this._assertWritePrecondition({
|
|
2911
|
+
collectionDir: chunkDir,
|
|
2912
|
+
resourceId: chunkId,
|
|
2913
|
+
ifMatch,
|
|
2914
|
+
ifNoneMatch
|
|
2915
|
+
})
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// The chunk directory is created lazily on first write (a binary body is
|
|
2919
|
+
// streamed into a temp file in it, so it must exist first).
|
|
2920
|
+
await mkdir(chunkDir, { recursive: true })
|
|
2921
|
+
await this._writeRepresentationBytes({ spaceId, filePath, input })
|
|
2922
|
+
|
|
2923
|
+
// A chunk has a single current representation: remove any prior one stored
|
|
2924
|
+
// under a different content-type (write-new-then-prune).
|
|
2925
|
+
const existing = await this._resourceFilesFor({
|
|
2926
|
+
collectionDir: chunkDir,
|
|
2927
|
+
resourceId: chunkId
|
|
2928
|
+
})
|
|
2929
|
+
await Promise.all(
|
|
2930
|
+
existing
|
|
2931
|
+
.filter(name => path.resolve(name) !== path.resolve(filePath))
|
|
2932
|
+
.map(name => rm(name))
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2935
|
+
// Bump the chunk's monotonic `version` (its ETag validator), preserving its
|
|
2936
|
+
// `createdAt`. A chunk carries no user Metadata / `createdBy` / epoch stamp.
|
|
2937
|
+
const now = new Date().toISOString()
|
|
2938
|
+
const prior = await this._readMetaSidecar({
|
|
2939
|
+
collectionDir: chunkDir,
|
|
2940
|
+
resourceId: chunkId
|
|
2941
|
+
})
|
|
2942
|
+
const version = (prior?.version ?? 0) + 1
|
|
2943
|
+
await this._writeMetaSidecar({
|
|
2944
|
+
collectionDir: chunkDir,
|
|
2945
|
+
resourceId: chunkId,
|
|
2946
|
+
sidecar: {
|
|
2947
|
+
createdAt: prior?.createdAt ?? now,
|
|
2948
|
+
updatedAt: now,
|
|
2949
|
+
version
|
|
2950
|
+
}
|
|
2951
|
+
})
|
|
2952
|
+
return { version }
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* Reads a chunk's bytes, resolving a `ResourceResult` (stream + resolved
|
|
2957
|
+
* content-type + the chunk's `version`). Throws `ResourceNotFoundError` (404)
|
|
2958
|
+
* when the chunk is absent.
|
|
2959
|
+
* @param options {object}
|
|
2960
|
+
* @param options.spaceId {string}
|
|
2961
|
+
* @param options.collectionId {string}
|
|
2962
|
+
* @param options.resourceId {string}
|
|
2963
|
+
* @param options.chunkIndex {number}
|
|
2964
|
+
* @returns {Promise<ResourceResult>}
|
|
2965
|
+
*/
|
|
2966
|
+
async getChunk({
|
|
2967
|
+
spaceId,
|
|
2968
|
+
collectionId,
|
|
2969
|
+
resourceId,
|
|
2970
|
+
chunkIndex
|
|
2971
|
+
}: {
|
|
2972
|
+
spaceId: string
|
|
2973
|
+
collectionId: string
|
|
2974
|
+
resourceId: string
|
|
2975
|
+
chunkIndex: number
|
|
2976
|
+
}): Promise<ResourceResult> {
|
|
2977
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
2978
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
2979
|
+
const chunkId = String(chunkIndex)
|
|
2980
|
+
const filePath = await this._findFile({
|
|
2981
|
+
collectionDir: chunkDir,
|
|
2982
|
+
resourceId: chunkId
|
|
2983
|
+
})
|
|
2984
|
+
if (!filePath) {
|
|
2985
|
+
throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
try {
|
|
2989
|
+
await fsStat(filePath)
|
|
2990
|
+
} catch (err) {
|
|
2991
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
2992
|
+
throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
|
|
2993
|
+
}
|
|
2994
|
+
throw err
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
const { contentType: storedResourceType } = parseResourceFileName(
|
|
2998
|
+
path.basename(filePath)
|
|
2999
|
+
)
|
|
3000
|
+
const sidecar = await this._readMetaSidecar({
|
|
3001
|
+
collectionDir: chunkDir,
|
|
3002
|
+
resourceId: chunkId
|
|
3003
|
+
})
|
|
3004
|
+
return {
|
|
3005
|
+
resourceStream: await openFileStream(filePath, this.logger),
|
|
3006
|
+
storedResourceType,
|
|
3007
|
+
...(sidecar?.version !== undefined && { version: sidecar.version })
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
/**
|
|
3012
|
+
* Reads a chunk's stored content-type / size / version (the HEAD payload
|
|
3013
|
+
* headers). Resolves `undefined` when the chunk is absent.
|
|
3014
|
+
* @param options {object}
|
|
3015
|
+
* @param options.spaceId {string}
|
|
3016
|
+
* @param options.collectionId {string}
|
|
3017
|
+
* @param options.resourceId {string}
|
|
3018
|
+
* @param options.chunkIndex {number}
|
|
3019
|
+
* @returns {Promise<{ contentType: string, size: number, version?: number } |
|
|
3020
|
+
* undefined>}
|
|
3021
|
+
*/
|
|
3022
|
+
async getChunkMetadata({
|
|
3023
|
+
spaceId,
|
|
3024
|
+
collectionId,
|
|
3025
|
+
resourceId,
|
|
3026
|
+
chunkIndex
|
|
3027
|
+
}: {
|
|
3028
|
+
spaceId: string
|
|
3029
|
+
collectionId: string
|
|
3030
|
+
resourceId: string
|
|
3031
|
+
chunkIndex: number
|
|
3032
|
+
}): Promise<
|
|
3033
|
+
{ contentType: string; size: number; version?: number } | undefined
|
|
3034
|
+
> {
|
|
3035
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
3036
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
3037
|
+
const chunkId = String(chunkIndex)
|
|
3038
|
+
const filePath = await this._findFile({
|
|
3039
|
+
collectionDir: chunkDir,
|
|
3040
|
+
resourceId: chunkId
|
|
3041
|
+
})
|
|
3042
|
+
if (!filePath) {
|
|
3043
|
+
return undefined
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
let stats
|
|
3047
|
+
try {
|
|
3048
|
+
stats = await fsStat(filePath)
|
|
3049
|
+
} catch (err) {
|
|
3050
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
3051
|
+
return undefined
|
|
3052
|
+
}
|
|
3053
|
+
throw err
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
const { contentType } = parseResourceFileName(path.basename(filePath))
|
|
3057
|
+
const sidecar = await this._readMetaSidecar({
|
|
3058
|
+
collectionDir: chunkDir,
|
|
3059
|
+
resourceId: chunkId
|
|
3060
|
+
})
|
|
3061
|
+
return {
|
|
3062
|
+
contentType,
|
|
3063
|
+
size: stats.size,
|
|
3064
|
+
...(sidecar?.version !== undefined && { version: sidecar.version })
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
/**
|
|
3069
|
+
* Deletes one chunk (a hard delete: its bytes and version sidecar both go, and
|
|
3070
|
+
* -- unlike a Resource -- it leaves no tombstone, since chunks are not part of
|
|
3071
|
+
* the change feed). Resolves `true` when a chunk was removed and `false` when
|
|
3072
|
+
* none was stored at that index. When `ifMatch` is supplied it is evaluated on
|
|
3073
|
+
* the chunk's current version atomically with the removal (under the same
|
|
3074
|
+
* per-Resource lock), throwing `PreconditionFailedError` (412) on a mismatch.
|
|
3075
|
+
* @param options {object}
|
|
3076
|
+
* @param options.spaceId {string}
|
|
3077
|
+
* @param options.collectionId {string}
|
|
3078
|
+
* @param options.resourceId {string}
|
|
3079
|
+
* @param options.chunkIndex {number}
|
|
3080
|
+
* @param [options.ifMatch] {string} `If-Match` precondition (a quoted ETag)
|
|
3081
|
+
* @returns {Promise<boolean>}
|
|
3082
|
+
*/
|
|
3083
|
+
async deleteChunk({
|
|
3084
|
+
spaceId,
|
|
3085
|
+
collectionId,
|
|
3086
|
+
resourceId,
|
|
3087
|
+
chunkIndex,
|
|
3088
|
+
ifMatch
|
|
3089
|
+
}: {
|
|
3090
|
+
spaceId: string
|
|
3091
|
+
collectionId: string
|
|
3092
|
+
resourceId: string
|
|
3093
|
+
chunkIndex: number
|
|
3094
|
+
ifMatch?: string
|
|
3095
|
+
}): Promise<boolean> {
|
|
3096
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
3097
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
3098
|
+
const chunkId = String(chunkIndex)
|
|
3099
|
+
return this._writeMutex.run(
|
|
3100
|
+
this._resourceLockKey({ spaceId, collectionId, resourceId }),
|
|
3101
|
+
async () => {
|
|
3102
|
+
const files = await this._resourceFilesFor({
|
|
3103
|
+
collectionDir: chunkDir,
|
|
3104
|
+
resourceId: chunkId
|
|
3105
|
+
})
|
|
3106
|
+
if (files.length === 0) {
|
|
3107
|
+
// Absent: the handler 404s on `false` (chunk deletes are not silently
|
|
3108
|
+
// idempotent, mirroring the EDV chunk contract).
|
|
3109
|
+
return false
|
|
3110
|
+
}
|
|
3111
|
+
if (ifMatch !== undefined) {
|
|
3112
|
+
await this._assertWritePrecondition({
|
|
3113
|
+
collectionDir: chunkDir,
|
|
3114
|
+
resourceId: chunkId,
|
|
3115
|
+
ifMatch
|
|
3116
|
+
})
|
|
3117
|
+
}
|
|
3118
|
+
await Promise.all(files.map(name => rm(name)))
|
|
3119
|
+
// Remove the version sidecar too: a chunk keeps no tombstone.
|
|
3120
|
+
await rm(
|
|
3121
|
+
this._metaSidecarPath({
|
|
3122
|
+
collectionDir: chunkDir,
|
|
3123
|
+
resourceId: chunkId
|
|
3124
|
+
}),
|
|
3125
|
+
{ force: true }
|
|
3126
|
+
)
|
|
3127
|
+
// When that was the last chunk, remove the now-empty chunk directory
|
|
3128
|
+
// itself: a lingering empty `.chunks.<encId>/` would otherwise appear
|
|
3129
|
+
// in the export walk (diverging from a Postgres export of the same
|
|
3130
|
+
// logical state) and count its allocated block toward the du-based
|
|
3131
|
+
// quota measurement. Safe under the per-Resource lock (`writeChunk`
|
|
3132
|
+
// serializes on the same key, so nothing lands in the directory
|
|
3133
|
+
// between the check and the rmdir).
|
|
3134
|
+
const remaining = await fs.promises.readdir(chunkDir)
|
|
3135
|
+
if (remaining.length === 0) {
|
|
3136
|
+
await fs.promises.rmdir(chunkDir)
|
|
3137
|
+
}
|
|
3138
|
+
// Freed bytes: drop the cached quota usage so the next write re-measures.
|
|
3139
|
+
this._usageCache.delete(spaceId)
|
|
3140
|
+
return true
|
|
3141
|
+
}
|
|
3142
|
+
)
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/**
|
|
3146
|
+
* Lists a Resource's stored chunks in ascending `index` order -- the
|
|
3147
|
+
* discovery/reassembly listing (the server never reassembles). Resolves an
|
|
3148
|
+
* empty listing when the Resource has no chunk directory (including when the
|
|
3149
|
+
* Resource itself is absent -- existence is the parent routes' concern).
|
|
3150
|
+
* @param options {object}
|
|
3151
|
+
* @param options.spaceId {string}
|
|
3152
|
+
* @param options.collectionId {string}
|
|
3153
|
+
* @param options.resourceId {string}
|
|
3154
|
+
* @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
|
|
3155
|
+
* number, contentType: string, version?: number }> }>}
|
|
3156
|
+
*/
|
|
3157
|
+
async listChunks({
|
|
3158
|
+
spaceId,
|
|
3159
|
+
collectionId,
|
|
3160
|
+
resourceId
|
|
3161
|
+
}: {
|
|
3162
|
+
spaceId: string
|
|
3163
|
+
collectionId: string
|
|
3164
|
+
resourceId: string
|
|
3165
|
+
}): Promise<{
|
|
3166
|
+
count: number
|
|
3167
|
+
chunks: Array<{
|
|
3168
|
+
index: number
|
|
3169
|
+
size: number
|
|
3170
|
+
contentType: string
|
|
3171
|
+
version?: number
|
|
3172
|
+
}>
|
|
3173
|
+
}> {
|
|
3174
|
+
const collectionDir = this._collectionDir({ spaceId, collectionId })
|
|
3175
|
+
const chunkDir = this._chunkDir({ collectionDir, resourceId })
|
|
3176
|
+
let entries: fs.Dirent[]
|
|
3177
|
+
try {
|
|
3178
|
+
entries = await fs.promises.readdir(chunkDir, { withFileTypes: true })
|
|
3179
|
+
} catch (err) {
|
|
3180
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
3181
|
+
return { count: 0, chunks: [] }
|
|
3182
|
+
}
|
|
3183
|
+
throw err
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// Keep only chunk representations (`r.<index>.<type>.<ext>`), dropping the
|
|
3187
|
+
// `.meta.<index>.json` version sidecars.
|
|
3188
|
+
const chunkEntries = entries.filter(
|
|
3189
|
+
entry => entry.isFile() && entry.name.startsWith('r.')
|
|
3190
|
+
)
|
|
3191
|
+
const chunks = await Promise.all(
|
|
3192
|
+
chunkEntries.map(async entry => {
|
|
3193
|
+
const { resourceId: indexStr, contentType } = parseResourceFileName(
|
|
3194
|
+
entry.name
|
|
3195
|
+
)
|
|
3196
|
+
const filePath = path.join(chunkDir, entry.name)
|
|
3197
|
+
const stats = await fsStat(filePath)
|
|
3198
|
+
const sidecar = await this._readMetaSidecar({
|
|
3199
|
+
collectionDir: chunkDir,
|
|
3200
|
+
resourceId: indexStr
|
|
3201
|
+
})
|
|
3202
|
+
return {
|
|
3203
|
+
index: Number(indexStr),
|
|
3204
|
+
size: stats.size,
|
|
3205
|
+
contentType,
|
|
3206
|
+
...(sidecar?.version !== undefined && { version: sidecar.version })
|
|
3207
|
+
}
|
|
3208
|
+
})
|
|
3209
|
+
)
|
|
3210
|
+
chunks.sort((left, right) => left.index - right.index)
|
|
3211
|
+
return { count: chunks.length, chunks }
|
|
3212
|
+
}
|
|
3213
|
+
|
|
2633
3214
|
/**
|
|
2634
3215
|
* Replication change feed (the `changes` query profile; see the
|
|
2635
3216
|
* `StorageBackend.changesSince` contract.
|