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
|
@@ -30,14 +30,15 @@ import * as tar from 'tar-stream';
|
|
|
30
30
|
import YAML from 'yaml';
|
|
31
31
|
import { StorageError, ResourceNotFoundError, SpaceNotFoundError, QuotaExceededError, CountQuotaExceededError, PayloadTooLargeError, PreconditionFailedError, KeystoreStateConflictError, KeyIdConflictError, DuplicateRevocationError } from '../errors.js';
|
|
32
32
|
import { applyMigrations } from './postgresSchema.js';
|
|
33
|
-
import { extractTarEntries, buildImportPlan } from '../lib/importTar.js';
|
|
33
|
+
import { extractTarEntries, buildImportPlan, metaSidecarFileId } from '../lib/importTar.js';
|
|
34
34
|
import { collectionPath, resourcePath } from '../lib/paths.js';
|
|
35
|
-
import { fileNameFor, parseResourceFileName } from '../lib/resourceFileName.js';
|
|
35
|
+
import { fileNameFor, parseResourceFileName, chunkDirName, parseChunkDirName, parseChunkIndexSegment } from '../lib/resourceFileName.js';
|
|
36
|
+
import { assertValidId } from '../lib/validateId.js';
|
|
36
37
|
import { sanitizeBackendRecord } from '../lib/backends.js';
|
|
37
38
|
import { backendUsageFields } from '../lib/backendUsage.js';
|
|
38
39
|
import { assertEncryptedWriteConforms } from '../lib/encryption.js';
|
|
39
40
|
import { encodeCursor, decodeCursor } from '../lib/cursor.js';
|
|
40
|
-
import { buildExportManifest } from '../lib/exportManifest.js';
|
|
41
|
+
import { buildExportManifest, EXPORT_ENTRY_MTIME } from '../lib/exportManifest.js';
|
|
41
42
|
import { revocationFileName } from '../lib/revocations.js';
|
|
42
43
|
import { isJson } from '../lib/isJson.js';
|
|
43
44
|
import { normalizeDescriptionWrite } from '../lib/collectionDescription.js';
|
|
@@ -277,11 +278,14 @@ export class PostgresBackend {
|
|
|
277
278
|
// `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
|
|
278
279
|
// keys on the `encryption` marker, a client-declared `epoch` stamp on
|
|
279
280
|
// Resources, and conditional (`If-Match`) Collection Description writes.
|
|
281
|
+
// `chunked-streams`: chunk addressing at `/{resourceId}/chunks/{n}`, opaque
|
|
282
|
+
// per-chunk raw-bytes storage in the `chunks` table.
|
|
280
283
|
features: [
|
|
281
284
|
'conditional-writes',
|
|
282
285
|
'changes-query',
|
|
283
286
|
'blinded-index-query',
|
|
284
|
-
'key-epochs'
|
|
287
|
+
'key-epochs',
|
|
288
|
+
'chunked-streams'
|
|
285
289
|
]
|
|
286
290
|
};
|
|
287
291
|
}
|
|
@@ -340,6 +344,30 @@ export class PostgresBackend {
|
|
|
340
344
|
});
|
|
341
345
|
}
|
|
342
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Serializes concurrent CREATORS of one not-yet-existing row (a Resource or
|
|
349
|
+
* a chunk) on a transaction-scoped advisory lock keyed by the row's
|
|
350
|
+
* identity. Under READ COMMITTED a `SELECT ... FOR UPDATE` on an absent row
|
|
351
|
+
* locks nothing (no gap locks), so two concurrent creators would both read
|
|
352
|
+
* "no prior row" and both apply their full byte size as the usage delta --
|
|
353
|
+
* and while the `ON CONFLICT` arm recomputes `version` from the conflicting
|
|
354
|
+
* row, it cannot retroactively fix the loser's delta, leaving `usage_bytes`
|
|
355
|
+
* inflated by one write's size. The caller takes this lock when its
|
|
356
|
+
* lock-nothing SELECT found no row, then RE-reads the row: the second
|
|
357
|
+
* creator blocks here until the first commits, and its re-read sees the
|
|
358
|
+
* committed row, so its precondition, version, and usage delta are computed
|
|
359
|
+
* from accurate state. Held to commit (advisory xact lock). The `create:`
|
|
360
|
+
* prefix keeps this key domain distinct from the unique-blinded-term
|
|
361
|
+
* advisory lock, which hashes the bare `(spaceId, collectionId)`.
|
|
362
|
+
* @param options {object}
|
|
363
|
+
* @param options.client {pg.PoolClient}
|
|
364
|
+
* @param options.spaceId {string}
|
|
365
|
+
* @param options.rowKey {string} the row's identity within the Space
|
|
366
|
+
* @returns {Promise<void>}
|
|
367
|
+
*/
|
|
368
|
+
async _lockSameKeyCreate({ client, spaceId, rowKey }) {
|
|
369
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))', [spaceId, `create:${rowKey}`]);
|
|
370
|
+
}
|
|
343
371
|
// Quotas
|
|
344
372
|
/**
|
|
345
373
|
* Reports the Space's usage from the transactional counter (no measurement
|
|
@@ -356,9 +384,16 @@ export class PostgresBackend {
|
|
|
356
384
|
const usageBytes = rows[0] ? Number(rows[0].usage_bytes) : 0;
|
|
357
385
|
let usageByCollection;
|
|
358
386
|
if (includeCollections) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
387
|
+
// Per-Collection usage sums both Resource content bytes and chunk bytes
|
|
388
|
+
// (the `chunked-streams` feature) so the breakdown agrees with the
|
|
389
|
+
// Space total in the transactional counter.
|
|
390
|
+
const { rows: collectionRows } = await this._pool.query(`SELECT collection_id, COALESCE(SUM(bytes), 0) AS usage FROM (
|
|
391
|
+
SELECT collection_id, size_bytes AS bytes FROM resources
|
|
392
|
+
WHERE space_id = $1
|
|
393
|
+
UNION ALL
|
|
394
|
+
SELECT collection_id, size AS bytes FROM chunks
|
|
395
|
+
WHERE space_id = $1
|
|
396
|
+
) usage_rows
|
|
362
397
|
GROUP BY collection_id
|
|
363
398
|
ORDER BY collection_id`, [spaceId]);
|
|
364
399
|
usageByCollection = collectionRows.map(row => ({
|
|
@@ -387,6 +422,8 @@ export class PostgresBackend {
|
|
|
387
422
|
(SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
|
|
388
423
|
AS space_total,
|
|
389
424
|
(SELECT COALESCE(SUM(size_bytes), 0) FROM resources
|
|
425
|
+
WHERE space_id = $1 AND collection_id = $2)
|
|
426
|
+
+ (SELECT COALESCE(SUM(size), 0) FROM chunks
|
|
390
427
|
WHERE space_id = $1 AND collection_id = $2) AS collection_total`, [spaceId, collectionId]);
|
|
391
428
|
const spaceTotalBytes = Number(rows[0]?.space_total ?? 0);
|
|
392
429
|
const usageBytes = Number(rows[0]?.collection_total ?? 0);
|
|
@@ -698,8 +735,14 @@ export class PostgresBackend {
|
|
|
698
735
|
*/
|
|
699
736
|
async deleteCollection({ spaceId, collectionId }) {
|
|
700
737
|
await this._withTransaction(async (client) => {
|
|
701
|
-
|
|
702
|
-
|
|
738
|
+
// The Collection's freed bytes are its Resource content plus its chunk
|
|
739
|
+
// bytes (the `chunked-streams` feature); both cascade away with the
|
|
740
|
+
// Collection row, so both leave the quota counter.
|
|
741
|
+
const { rows } = await client.query(`SELECT
|
|
742
|
+
(SELECT COALESCE(SUM(size_bytes), 0) FROM resources
|
|
743
|
+
WHERE space_id = $1 AND collection_id = $2)
|
|
744
|
+
+ (SELECT COALESCE(SUM(size), 0) FROM chunks
|
|
745
|
+
WHERE space_id = $1 AND collection_id = $2) AS total`, [spaceId, collectionId]);
|
|
703
746
|
const freedBytes = Number(rows[0]?.total ?? 0);
|
|
704
747
|
await client.query(`DELETE FROM collections WHERE space_id = $1 AND collection_id = $2`, [spaceId, collectionId]);
|
|
705
748
|
// Collection- and Resource-level policies live under the Collection (the
|
|
@@ -887,7 +930,23 @@ export class PostgresBackend {
|
|
|
887
930
|
FROM resources
|
|
888
931
|
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
889
932
|
FOR UPDATE`, [spaceId, collectionId, resourceId]);
|
|
890
|
-
|
|
933
|
+
let prior = rows[0];
|
|
934
|
+
if (prior === undefined) {
|
|
935
|
+
// The lock-nothing case: serialize with any concurrent creator of the
|
|
936
|
+
// same id and re-read, so `exists` / `priorSize` below reflect the row
|
|
937
|
+
// it committed (see `_lockSameKeyCreate` -- without this, both
|
|
938
|
+
// creators would count their full byte size as the usage delta).
|
|
939
|
+
await this._lockSameKeyCreate({
|
|
940
|
+
client,
|
|
941
|
+
spaceId,
|
|
942
|
+
rowKey: `${collectionId}/${resourceId}`
|
|
943
|
+
});
|
|
944
|
+
const { rows: reread } = await client.query(`SELECT version, size_bytes, deleted, created_at, created_by
|
|
945
|
+
FROM resources
|
|
946
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
947
|
+
FOR UPDATE`, [spaceId, collectionId, resourceId]);
|
|
948
|
+
prior = reread[0];
|
|
949
|
+
}
|
|
891
950
|
const exists = prior !== undefined && !prior.deleted;
|
|
892
951
|
if (ifMatch !== undefined || ifNoneMatch) {
|
|
893
952
|
assertWritePrecondition({
|
|
@@ -914,7 +973,7 @@ export class PostgresBackend {
|
|
|
914
973
|
}
|
|
915
974
|
const now = new Date().toISOString();
|
|
916
975
|
const version = (prior?.version ?? 0) + 1;
|
|
917
|
-
const priorSize = exists ? Number(prior
|
|
976
|
+
const priorSize = exists ? Number(prior?.size_bytes ?? 0) : 0;
|
|
918
977
|
const delta = content.length - priorSize;
|
|
919
978
|
if (delta !== 0) {
|
|
920
979
|
await this._applyUsageDelta({ client, spaceId, delta });
|
|
@@ -924,10 +983,11 @@ export class PostgresBackend {
|
|
|
924
983
|
// dropped both (the metadata went with the deleted Resource).
|
|
925
984
|
//
|
|
926
985
|
// Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
|
|
927
|
-
// (a tombstone is a real row and stays lock-serialized),
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
986
|
+
// (a tombstone is a real row and stays lock-serialized), concurrent
|
|
987
|
+
// creators through this method are already serialized by
|
|
988
|
+
// `_lockSameKeyCreate` above -- but a writer that does not take that
|
|
989
|
+
// lock (`importSpace`'s plain INSERTs) can still race. A plain INSERT
|
|
990
|
+
// (no ON CONFLICT) keeps the primary key as the arbiter: the loser's
|
|
931
991
|
// unique violation maps to the 412 the precondition would have thrown.
|
|
932
992
|
// `createdBy` names the Resource's creator, not its last writer: taken
|
|
933
993
|
// from this write's invoker only when there is no prior row at all
|
|
@@ -1062,7 +1122,19 @@ export class PostgresBackend {
|
|
|
1062
1122
|
// no-op, keeping an existing tombstone's change-feed entry stable.
|
|
1063
1123
|
return;
|
|
1064
1124
|
}
|
|
1065
|
-
|
|
1125
|
+
// A soft delete is an UPDATE, not a row removal, so the chunk foreign
|
|
1126
|
+
// key's ON DELETE CASCADE does not fire -- remove the Resource's chunks
|
|
1127
|
+
// (the `chunked-streams` feature) explicitly in this same transaction so
|
|
1128
|
+
// they never outlive their parent, and return their bytes to the quota
|
|
1129
|
+
// counter alongside the Resource's content bytes.
|
|
1130
|
+
const { rows: chunkRows } = await client.query(`SELECT COALESCE(SUM(size), 0) AS total FROM chunks
|
|
1131
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
|
|
1132
|
+
const freedChunkBytes = Number(chunkRows[0]?.total ?? 0);
|
|
1133
|
+
if (freedChunkBytes > 0) {
|
|
1134
|
+
await client.query(`DELETE FROM chunks
|
|
1135
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
|
|
1136
|
+
}
|
|
1137
|
+
const freedBytes = Number(prior.size_bytes) + freedChunkBytes;
|
|
1066
1138
|
if (freedBytes > 0) {
|
|
1067
1139
|
await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
|
|
1068
1140
|
}
|
|
@@ -1168,6 +1240,276 @@ export class PostgresBackend {
|
|
|
1168
1240
|
return { metaVersion };
|
|
1169
1241
|
});
|
|
1170
1242
|
}
|
|
1243
|
+
// Chunks (the `chunked-streams` feature)
|
|
1244
|
+
/**
|
|
1245
|
+
* Writes one chunk of a chunked Resource as one transaction: the parent
|
|
1246
|
+
* Resource must exist (checked atomically -- a `FOR SHARE` lock on it also
|
|
1247
|
+
* blocks a concurrent delete of the parent for the duration of the write, so
|
|
1248
|
+
* a chunk can never be orphaned by a racing `deleteResource`), the chunk row
|
|
1249
|
+
* is locked, its precondition evaluated, its monotonic `version` bumped, and
|
|
1250
|
+
* the transactional quota delta applied. The chunk body is stored opaquely as
|
|
1251
|
+
* a single `bytea`, the buffered-blob path (bounded by `maxUploadBytes`),
|
|
1252
|
+
* exactly like a binary Resource representation.
|
|
1253
|
+
* @param options {object}
|
|
1254
|
+
* @param options.spaceId {string}
|
|
1255
|
+
* @param options.collectionId {string}
|
|
1256
|
+
* @param options.resourceId {string}
|
|
1257
|
+
* @param options.chunkIndex {number} a non-negative safe integer
|
|
1258
|
+
* @param options.input {ResourceInput}
|
|
1259
|
+
* @param [options.ifMatch] {string}
|
|
1260
|
+
* @param [options.ifNoneMatch] {boolean}
|
|
1261
|
+
* @returns {Promise<{ version: number }>} the chunk's new version
|
|
1262
|
+
*/
|
|
1263
|
+
async writeChunk({ spaceId, collectionId, resourceId, chunkIndex, input, ifMatch, ifNoneMatch }) {
|
|
1264
|
+
const { maxUploadBytes } = this;
|
|
1265
|
+
let bytes;
|
|
1266
|
+
if (input.kind === 'json') {
|
|
1267
|
+
bytes = Buffer.from(JSON.stringify(input.data));
|
|
1268
|
+
if (bytes.length > maxUploadBytes) {
|
|
1269
|
+
throw new PayloadTooLargeError({
|
|
1270
|
+
maxUploadBytes,
|
|
1271
|
+
backendId: this.describe().id,
|
|
1272
|
+
uploadBytes: bytes.length
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
// Pre-flight a declared size, then buffer through the counting guard that
|
|
1278
|
+
// hard-caps a body whose size is omitted or understated. Buffering
|
|
1279
|
+
// happens BEFORE the transaction so a slow upload holds no row lock.
|
|
1280
|
+
if (input.declaredBytes !== undefined &&
|
|
1281
|
+
input.declaredBytes > maxUploadBytes) {
|
|
1282
|
+
throw new PayloadTooLargeError({
|
|
1283
|
+
maxUploadBytes,
|
|
1284
|
+
backendId: this.describe().id,
|
|
1285
|
+
uploadBytes: input.declaredBytes
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
bytes = await bufferStreamCapped({
|
|
1289
|
+
stream: input.stream,
|
|
1290
|
+
maxUploadBytes,
|
|
1291
|
+
backendId: this.describe().id
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
return this._withTransaction(async (client) => {
|
|
1295
|
+
// Parent Resource must exist (and not be a tombstone). `FOR SHARE`
|
|
1296
|
+
// conflicts with the `FOR UPDATE` a concurrent `deleteResource` takes, so
|
|
1297
|
+
// the two serialize on the parent row -- the parent cannot be deleted
|
|
1298
|
+
// between this check and the chunk write.
|
|
1299
|
+
const { rows: parentRows } = await client.query(`SELECT deleted FROM resources
|
|
1300
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1301
|
+
FOR SHARE`, [spaceId, collectionId, resourceId]);
|
|
1302
|
+
const parent = parentRows[0];
|
|
1303
|
+
if (!parent || parent.deleted) {
|
|
1304
|
+
throw new ResourceNotFoundError({ requestName: 'Write Chunk' });
|
|
1305
|
+
}
|
|
1306
|
+
// Lock the chunk row (if any) and read its current version/size, so the
|
|
1307
|
+
// precondition, the monotonic bump, and the usage delta are all atomic
|
|
1308
|
+
// with the write.
|
|
1309
|
+
const { rows } = await client.query(`SELECT version, size FROM chunks
|
|
1310
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1311
|
+
AND chunk_index = $4
|
|
1312
|
+
FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1313
|
+
let prior = rows[0];
|
|
1314
|
+
const chunkLabel = `${resourceId}/chunks/${chunkIndex}`;
|
|
1315
|
+
if (prior === undefined) {
|
|
1316
|
+
// The lock-nothing case: serialize with any concurrent creator of the
|
|
1317
|
+
// same chunk and re-read, so the usage delta below reflects the row it
|
|
1318
|
+
// committed (see `_lockSameKeyCreate`).
|
|
1319
|
+
await this._lockSameKeyCreate({
|
|
1320
|
+
client,
|
|
1321
|
+
spaceId,
|
|
1322
|
+
rowKey: `${collectionId}/${chunkLabel}`
|
|
1323
|
+
});
|
|
1324
|
+
const { rows: reread } = await client.query(`SELECT version, size FROM chunks
|
|
1325
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1326
|
+
AND chunk_index = $4
|
|
1327
|
+
FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1328
|
+
prior = reread[0];
|
|
1329
|
+
}
|
|
1330
|
+
const exists = prior !== undefined;
|
|
1331
|
+
if (ifMatch !== undefined || ifNoneMatch) {
|
|
1332
|
+
assertWritePrecondition({
|
|
1333
|
+
resourceId: chunkLabel,
|
|
1334
|
+
exists,
|
|
1335
|
+
currentVersion: prior?.version ?? 0,
|
|
1336
|
+
ifMatch,
|
|
1337
|
+
ifNoneMatch
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
const version = (prior?.version ?? 0) + 1;
|
|
1341
|
+
const priorSize = exists ? Number(prior?.size ?? 0) : 0;
|
|
1342
|
+
const delta = bytes.length - priorSize;
|
|
1343
|
+
if (delta !== 0) {
|
|
1344
|
+
await this._applyUsageDelta({ client, spaceId, delta });
|
|
1345
|
+
}
|
|
1346
|
+
const values = [
|
|
1347
|
+
spaceId,
|
|
1348
|
+
collectionId,
|
|
1349
|
+
resourceId,
|
|
1350
|
+
chunkIndex,
|
|
1351
|
+
input.contentType,
|
|
1352
|
+
bytes,
|
|
1353
|
+
bytes.length,
|
|
1354
|
+
version
|
|
1355
|
+
];
|
|
1356
|
+
const insertSql = `
|
|
1357
|
+
INSERT INTO chunks (
|
|
1358
|
+
space_id, collection_id, resource_id, chunk_index,
|
|
1359
|
+
content_type, bytes, size, version
|
|
1360
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`;
|
|
1361
|
+
// Create-if-absent atomicity mirrors `writeResource`: concurrent
|
|
1362
|
+
// creators through this method are serialized by `_lockSameKeyCreate`
|
|
1363
|
+
// above, and against a writer that does not take that lock
|
|
1364
|
+
// (`importSpace`'s plain INSERTs) the plain INSERT keeps the primary key
|
|
1365
|
+
// as the arbiter -- a racing creator's unique violation maps to the 412
|
|
1366
|
+
// the precondition would have thrown.
|
|
1367
|
+
if (ifNoneMatch && prior === undefined) {
|
|
1368
|
+
try {
|
|
1369
|
+
await client.query(insertSql, values);
|
|
1370
|
+
}
|
|
1371
|
+
catch (err) {
|
|
1372
|
+
if (err.code === '23505') {
|
|
1373
|
+
throw new PreconditionFailedError({
|
|
1374
|
+
detail: `Chunk '${chunkLabel}' already exists (If-None-Match: *).`
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
throw err;
|
|
1378
|
+
}
|
|
1379
|
+
return { version };
|
|
1380
|
+
}
|
|
1381
|
+
// The conflict update derives `version` from the row (`chunks.version +
|
|
1382
|
+
// 1`), not from the pre-read, so a concurrent creator that slipped in
|
|
1383
|
+
// after our lock-nothing SELECT still advances the counter monotonically.
|
|
1384
|
+
// RETURNING reports the version that actually landed.
|
|
1385
|
+
const { rows: written } = await client.query(`${insertSql}
|
|
1386
|
+
ON CONFLICT (space_id, collection_id, resource_id, chunk_index)
|
|
1387
|
+
DO UPDATE SET
|
|
1388
|
+
content_type = EXCLUDED.content_type,
|
|
1389
|
+
bytes = EXCLUDED.bytes,
|
|
1390
|
+
size = EXCLUDED.size,
|
|
1391
|
+
version = chunks.version + 1
|
|
1392
|
+
RETURNING version`, values);
|
|
1393
|
+
return { version: written[0].version };
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Reads a chunk's bytes. Rejects with `ResourceNotFoundError` (404) when no
|
|
1398
|
+
* chunk is stored at that index.
|
|
1399
|
+
* @param options {object}
|
|
1400
|
+
* @param options.spaceId {string}
|
|
1401
|
+
* @param options.collectionId {string}
|
|
1402
|
+
* @param options.resourceId {string}
|
|
1403
|
+
* @param options.chunkIndex {number}
|
|
1404
|
+
* @returns {Promise<ResourceResult>}
|
|
1405
|
+
*/
|
|
1406
|
+
async getChunk({ spaceId, collectionId, resourceId, chunkIndex }) {
|
|
1407
|
+
const { rows } = await this._pool.query(`SELECT content_type, bytes, version FROM chunks
|
|
1408
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1409
|
+
AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1410
|
+
const row = rows[0];
|
|
1411
|
+
if (!row) {
|
|
1412
|
+
throw new ResourceNotFoundError({ requestName: 'Get Chunk' });
|
|
1413
|
+
}
|
|
1414
|
+
return {
|
|
1415
|
+
resourceStream: Readable.from(row.bytes),
|
|
1416
|
+
storedResourceType: row.content_type,
|
|
1417
|
+
version: row.version
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Reads a chunk's stored content-type / size / version (the HEAD payload
|
|
1422
|
+
* headers). Resolves `undefined` when the chunk is absent.
|
|
1423
|
+
* @param options {object}
|
|
1424
|
+
* @param options.spaceId {string}
|
|
1425
|
+
* @param options.collectionId {string}
|
|
1426
|
+
* @param options.resourceId {string}
|
|
1427
|
+
* @param options.chunkIndex {number}
|
|
1428
|
+
* @returns {Promise<{ contentType: string, size: number, version?: number }
|
|
1429
|
+
* | undefined>}
|
|
1430
|
+
*/
|
|
1431
|
+
async getChunkMetadata({ spaceId, collectionId, resourceId, chunkIndex }) {
|
|
1432
|
+
const { rows } = await this._pool.query(`SELECT content_type, size, version FROM chunks
|
|
1433
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1434
|
+
AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1435
|
+
const row = rows[0];
|
|
1436
|
+
if (!row) {
|
|
1437
|
+
return undefined;
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
contentType: row.content_type,
|
|
1441
|
+
size: Number(row.size),
|
|
1442
|
+
version: row.version
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Deletes one chunk as one transaction (the chunk row is locked, its
|
|
1447
|
+
* `ifMatch` precondition evaluated atomically, and its bytes returned to the
|
|
1448
|
+
* quota counter). Resolves `true` when a chunk was removed, `false` when none
|
|
1449
|
+
* was stored at that index (the handler 404s on `false` -- chunk deletes are
|
|
1450
|
+
* not silently idempotent, unlike `deleteResource`).
|
|
1451
|
+
* @param options {object}
|
|
1452
|
+
* @param options.spaceId {string}
|
|
1453
|
+
* @param options.collectionId {string}
|
|
1454
|
+
* @param options.resourceId {string}
|
|
1455
|
+
* @param options.chunkIndex {number}
|
|
1456
|
+
* @param [options.ifMatch] {string}
|
|
1457
|
+
* @returns {Promise<boolean>}
|
|
1458
|
+
*/
|
|
1459
|
+
async deleteChunk({ spaceId, collectionId, resourceId, chunkIndex, ifMatch }) {
|
|
1460
|
+
return this._withTransaction(async (client) => {
|
|
1461
|
+
const { rows } = await client.query(`SELECT version, size FROM chunks
|
|
1462
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1463
|
+
AND chunk_index = $4
|
|
1464
|
+
FOR UPDATE`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1465
|
+
const prior = rows[0];
|
|
1466
|
+
if (prior === undefined) {
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
if (ifMatch !== undefined) {
|
|
1470
|
+
assertWritePrecondition({
|
|
1471
|
+
resourceId: `${resourceId}/chunks/${chunkIndex}`,
|
|
1472
|
+
exists: true,
|
|
1473
|
+
currentVersion: prior.version,
|
|
1474
|
+
ifMatch
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
await client.query(`DELETE FROM chunks
|
|
1478
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1479
|
+
AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
1480
|
+
const freedBytes = Number(prior.size);
|
|
1481
|
+
if (freedBytes > 0) {
|
|
1482
|
+
await this._applyUsageDelta({ client, spaceId, delta: -freedBytes });
|
|
1483
|
+
}
|
|
1484
|
+
return true;
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Lists a Resource's stored chunks in ascending `chunk_index` order -- the
|
|
1489
|
+
* discovery/reassembly listing. The opaque `bytes` column is deliberately not
|
|
1490
|
+
* selected. An empty listing (Resource with no chunks, or an absent Resource)
|
|
1491
|
+
* resolves `{ count: 0, chunks: [] }`.
|
|
1492
|
+
* @param options {object}
|
|
1493
|
+
* @param options.spaceId {string}
|
|
1494
|
+
* @param options.collectionId {string}
|
|
1495
|
+
* @param options.resourceId {string}
|
|
1496
|
+
* @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
|
|
1497
|
+
* number, contentType: string, version?: number }> }>}
|
|
1498
|
+
*/
|
|
1499
|
+
async listChunks({ spaceId, collectionId, resourceId }) {
|
|
1500
|
+
const { rows } = await this._pool.query(`SELECT chunk_index, size, content_type, version FROM chunks
|
|
1501
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1502
|
+
ORDER BY chunk_index`, [spaceId, collectionId, resourceId]);
|
|
1503
|
+
return {
|
|
1504
|
+
count: rows.length,
|
|
1505
|
+
chunks: rows.map(row => ({
|
|
1506
|
+
index: row.chunk_index,
|
|
1507
|
+
size: Number(row.size),
|
|
1508
|
+
contentType: row.content_type,
|
|
1509
|
+
version: row.version
|
|
1510
|
+
}))
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1171
1513
|
/**
|
|
1172
1514
|
* Replication change feed (the `changes` query profile): one indexed keyset
|
|
1173
1515
|
* query over `(updatedAt, resourceId)`, tombstones included, JSON documents
|
|
@@ -1678,7 +2020,7 @@ export class PostgresBackend {
|
|
|
1678
2020
|
if (!spaceDescription) {
|
|
1679
2021
|
throw new SpaceNotFoundError({ requestName: 'Export Space' });
|
|
1680
2022
|
}
|
|
1681
|
-
const [{ rows: policyRows }, { rows: collectionRows }, { rows: resourceRows }, { rows: revocationRows }] = await Promise.all([
|
|
2023
|
+
const [{ rows: policyRows }, { rows: collectionRows }, { rows: resourceRows }, { rows: revocationRows }, { rows: chunkRows }] = await Promise.all([
|
|
1682
2024
|
this._pool.query(`SELECT collection_id, resource_id, policy FROM policies
|
|
1683
2025
|
WHERE space_id = $1`, [spaceId]),
|
|
1684
2026
|
this._pool.query(`SELECT collection_id, description, description_version FROM collections
|
|
@@ -1690,7 +2032,12 @@ export class PostgresBackend {
|
|
|
1690
2032
|
created_at, updated_at, created_by
|
|
1691
2033
|
FROM resources WHERE space_id = $1`, [spaceId]),
|
|
1692
2034
|
this._pool.query(`SELECT delegator, capability_id, record FROM space_revocations
|
|
1693
|
-
WHERE space_id = $1`, [spaceId])
|
|
2035
|
+
WHERE space_id = $1`, [spaceId]),
|
|
2036
|
+
// Chunk metadata only -- bytes are fetched one chunk at a time while
|
|
2037
|
+
// packing, so an export never holds a chunked Resource whole in memory.
|
|
2038
|
+
this._pool.query(`SELECT collection_id, resource_id, chunk_index, content_type, version
|
|
2039
|
+
FROM chunks WHERE space_id = $1
|
|
2040
|
+
ORDER BY collection_id, resource_id, chunk_index`, [spaceId])
|
|
1694
2041
|
]);
|
|
1695
2042
|
// Assemble the per-entry file lists in the filesystem's shapes: files are
|
|
1696
2043
|
// named by the shared codecs and sorted with localeCompare, matching the
|
|
@@ -1765,6 +2112,46 @@ export class PostgresBackend {
|
|
|
1765
2112
|
});
|
|
1766
2113
|
}
|
|
1767
2114
|
}
|
|
2115
|
+
// Chunks (the `chunked-streams` feature): each chunked Resource contributes
|
|
2116
|
+
// one `.chunks.<encResourceId>/` subdirectory in its Collection dir, in the
|
|
2117
|
+
// exact filesystem-backend layout so an archive imports into either backend.
|
|
2118
|
+
// A chunk is stored there as a Resource keyed by its stringified index: an
|
|
2119
|
+
// `r.<index>.<encContentType>.<ext>` representation file (`fileNameFor`)
|
|
2120
|
+
// plus a `.meta.<index>.json` version sidecar. Files within a chunk dir are
|
|
2121
|
+
// sorted by name (the filesystem's readdir sort). Rows arrive ordered by
|
|
2122
|
+
// `(collection, resource, index)`.
|
|
2123
|
+
const chunkDirsByResource = new Map();
|
|
2124
|
+
for (const row of chunkRows) {
|
|
2125
|
+
const dirKey = `${row.collection_id}/${row.resource_id}`;
|
|
2126
|
+
let chunkFiles = chunkDirsByResource.get(dirKey);
|
|
2127
|
+
if (!chunkFiles) {
|
|
2128
|
+
chunkFiles = [];
|
|
2129
|
+
chunkDirsByResource.set(dirKey, chunkFiles);
|
|
2130
|
+
filesFor(row.collection_id).push({
|
|
2131
|
+
name: chunkDirName(row.resource_id),
|
|
2132
|
+
chunkDir: chunkFiles
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
const chunkId = String(row.chunk_index);
|
|
2136
|
+
chunkFiles.push({
|
|
2137
|
+
name: fileNameFor({
|
|
2138
|
+
resourceId: chunkId,
|
|
2139
|
+
contentType: row.content_type
|
|
2140
|
+
}),
|
|
2141
|
+
chunk: {
|
|
2142
|
+
collectionId: row.collection_id,
|
|
2143
|
+
resourceId: row.resource_id,
|
|
2144
|
+
chunkIndex: row.chunk_index
|
|
2145
|
+
}
|
|
2146
|
+
});
|
|
2147
|
+
chunkFiles.push({
|
|
2148
|
+
name: `.meta.${chunkId}.json`,
|
|
2149
|
+
bytes: Buffer.from(JSON.stringify({ version: row.version }))
|
|
2150
|
+
});
|
|
2151
|
+
}
|
|
2152
|
+
for (const chunkFiles of chunkDirsByResource.values()) {
|
|
2153
|
+
chunkFiles.sort((left, right) => left.name.localeCompare(right.name));
|
|
2154
|
+
}
|
|
1768
2155
|
// Top-level order: space-level files and collection dirs interleaved,
|
|
1769
2156
|
// sorted by name -- the same order the filesystem's readdir+sort yields.
|
|
1770
2157
|
const topLevel = [
|
|
@@ -1791,33 +2178,59 @@ export class PostgresBackend {
|
|
|
1791
2178
|
const manifest = buildExportManifest({
|
|
1792
2179
|
spaceId,
|
|
1793
2180
|
entries: topLevel.map(entry => entry.kind === 'collection'
|
|
1794
|
-
? {
|
|
2181
|
+
? {
|
|
2182
|
+
name: entry.name,
|
|
2183
|
+
// A chunk directory expands to its `.chunks.<encId>/<file>`
|
|
2184
|
+
// relative paths (mirroring the pack order), so the manifest
|
|
2185
|
+
// matches the filesystem backend's for the same Space.
|
|
2186
|
+
files: entry.files.flatMap(file => 'chunkDir' in file
|
|
2187
|
+
? file.chunkDir.map(child => `${file.name}/${child.name}`)
|
|
2188
|
+
: [file.name])
|
|
2189
|
+
}
|
|
1795
2190
|
: { name: entry.name }),
|
|
1796
2191
|
revocationFiles: revocationFiles.map(file => file.name)
|
|
1797
2192
|
});
|
|
2193
|
+
// Fixed mtime on every entry so the archive is byte-reproducible (see
|
|
2194
|
+
// EXPORT_ENTRY_MTIME).
|
|
2195
|
+
const mtime = EXPORT_ENTRY_MTIME;
|
|
1798
2196
|
const pack = tar.pack();
|
|
1799
|
-
pack.entry({ name: 'manifest.yml' }, YAML.stringify(manifest));
|
|
1800
|
-
pack.entry({ name: 'space/', type: 'directory' });
|
|
1801
|
-
pack.entry({ name: `space/${spaceId}/`, type: 'directory' });
|
|
2197
|
+
pack.entry({ name: 'manifest.yml', mtime }, YAML.stringify(manifest));
|
|
2198
|
+
pack.entry({ name: 'space/', type: 'directory', mtime });
|
|
2199
|
+
pack.entry({ name: `space/${spaceId}/`, type: 'directory', mtime });
|
|
1802
2200
|
for (const entry of topLevel) {
|
|
1803
2201
|
const entryTarget = `space/${spaceId}/${entry.name}`;
|
|
1804
2202
|
if (entry.kind === 'collection') {
|
|
1805
|
-
pack.entry({ name: `${entryTarget}/`, type: 'directory' });
|
|
2203
|
+
pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime });
|
|
1806
2204
|
for (const file of entry.files) {
|
|
2205
|
+
if ('chunkDir' in file) {
|
|
2206
|
+
// A chunked Resource's `.chunks.<encId>/` subdirectory: emit the
|
|
2207
|
+
// directory entry, then each file -- an inline `.meta.<index>.json`
|
|
2208
|
+
// sidecar, or a chunk representation whose bytes are fetched one at
|
|
2209
|
+
// a time.
|
|
2210
|
+
const dirTarget = `${entryTarget}/${file.name}`;
|
|
2211
|
+
pack.entry({ name: `${dirTarget}/`, type: 'directory', mtime });
|
|
2212
|
+
for (const chunkFile of file.chunkDir) {
|
|
2213
|
+
const bytes = 'bytes' in chunkFile
|
|
2214
|
+
? chunkFile.bytes
|
|
2215
|
+
: await this._chunkContent({ spaceId, ...chunkFile.chunk });
|
|
2216
|
+
pack.entry({ name: `${dirTarget}/${chunkFile.name}`, mtime }, bytes);
|
|
2217
|
+
}
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
1807
2220
|
const bytes = 'bytes' in file
|
|
1808
2221
|
? file.bytes
|
|
1809
2222
|
: await this._resourceContent({ spaceId, ...file.resource });
|
|
1810
|
-
pack.entry({ name: `${entryTarget}/${file.name}
|
|
2223
|
+
pack.entry({ name: `${entryTarget}/${file.name}`, mtime }, bytes);
|
|
1811
2224
|
}
|
|
1812
2225
|
}
|
|
1813
2226
|
else {
|
|
1814
|
-
pack.entry({ name: entryTarget }, entry.bytes);
|
|
2227
|
+
pack.entry({ name: entryTarget, mtime }, entry.bytes);
|
|
1815
2228
|
}
|
|
1816
2229
|
}
|
|
1817
2230
|
if (revocationFiles.length > 0) {
|
|
1818
|
-
pack.entry({ name: 'revocations/', type: 'directory' });
|
|
2231
|
+
pack.entry({ name: 'revocations/', type: 'directory', mtime });
|
|
1819
2232
|
for (const file of revocationFiles) {
|
|
1820
|
-
pack.entry({ name: `revocations/${file.name}
|
|
2233
|
+
pack.entry({ name: `revocations/${file.name}`, mtime }, file.bytes);
|
|
1821
2234
|
}
|
|
1822
2235
|
}
|
|
1823
2236
|
pack.finalize();
|
|
@@ -1839,6 +2252,24 @@ export class PostgresBackend {
|
|
|
1839
2252
|
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, collectionId, resourceId]);
|
|
1840
2253
|
return rows[0]?.content ?? Buffer.alloc(0);
|
|
1841
2254
|
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Fetches one chunk's bytes for the export pack loop (the `chunked-streams`
|
|
2257
|
+
* feature). A chunk removed between the metadata pass and this read yields an
|
|
2258
|
+
* empty body rather than failing the whole archive, matching
|
|
2259
|
+
* `_resourceContent`.
|
|
2260
|
+
* @param options {object}
|
|
2261
|
+
* @param options.spaceId {string}
|
|
2262
|
+
* @param options.collectionId {string}
|
|
2263
|
+
* @param options.resourceId {string}
|
|
2264
|
+
* @param options.chunkIndex {number}
|
|
2265
|
+
* @returns {Promise<Buffer>}
|
|
2266
|
+
*/
|
|
2267
|
+
async _chunkContent({ spaceId, collectionId, resourceId, chunkIndex }) {
|
|
2268
|
+
const { rows } = await this._pool.query(`SELECT bytes FROM chunks
|
|
2269
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2270
|
+
AND chunk_index = $4`, [spaceId, collectionId, resourceId, chunkIndex]);
|
|
2271
|
+
return rows[0]?.bytes ?? Buffer.alloc(0);
|
|
2272
|
+
}
|
|
1842
2273
|
/**
|
|
1843
2274
|
* Merges a WAS space-export tarball into an existing Space with the same
|
|
1844
2275
|
* three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
|
|
@@ -1855,6 +2286,10 @@ export class PostgresBackend {
|
|
|
1855
2286
|
async importSpace({ spaceId, tarStream }) {
|
|
1856
2287
|
const entries = await extractTarEntries(tarStream);
|
|
1857
2288
|
const { spacePolicy, collections, revocations } = buildImportPlan(entries);
|
|
2289
|
+
// Chunk entries (the `chunked-streams` feature) live in per-Resource
|
|
2290
|
+
// `.chunks.<encId>/` subdirectories, which `buildImportPlan` skips (it
|
|
2291
|
+
// ignores nested files); parse them straight off the raw tar entries.
|
|
2292
|
+
const chunkEntries = this._chunkEntriesFromArchive(entries);
|
|
1858
2293
|
const { capacityBytes, maxUploadBytes, maxCollectionsPerSpace, maxResourcesPerSpace } = this;
|
|
1859
2294
|
return this._withTransaction(async (client) => {
|
|
1860
2295
|
await this._ensureSpaceRow({ client, spaceId });
|
|
@@ -1918,6 +2353,19 @@ export class PostgresBackend {
|
|
|
1918
2353
|
incomingBytes += body.length;
|
|
1919
2354
|
}
|
|
1920
2355
|
}
|
|
2356
|
+
// Chunks (the `chunked-streams` feature) are opaque bytes -- no
|
|
2357
|
+
// encryption-conformance check applies -- but they count the same per-body
|
|
2358
|
+
// 413 and the (conservative) capacity pre-flight as Resource bodies.
|
|
2359
|
+
for (const chunk of chunkEntries) {
|
|
2360
|
+
if (chunk.body.length > maxUploadBytes) {
|
|
2361
|
+
throw new PayloadTooLargeError({
|
|
2362
|
+
maxUploadBytes,
|
|
2363
|
+
backendId: this.describe().id,
|
|
2364
|
+
uploadBytes: chunk.body.length
|
|
2365
|
+
});
|
|
2366
|
+
}
|
|
2367
|
+
incomingBytes += chunk.body.length;
|
|
2368
|
+
}
|
|
1921
2369
|
if (capacityBytes !== undefined &&
|
|
1922
2370
|
currentUsage + incomingBytes > capacityBytes) {
|
|
1923
2371
|
throw new QuotaExceededError({ spaceId, capacityBytes });
|
|
@@ -2081,13 +2529,53 @@ export class PostgresBackend {
|
|
|
2081
2529
|
stats.resourcesCreated++;
|
|
2082
2530
|
}
|
|
2083
2531
|
}
|
|
2084
|
-
|
|
2532
|
+
// Chunks (the `chunked-streams` feature): restore each archived chunk
|
|
2533
|
+
// skip-not-overwrite, after the Resource apply loop so a chunk's parent
|
|
2534
|
+
// Resource row already exists in this transaction (the foreign key
|
|
2535
|
+
// requires it). An orphan chunk -- one whose parent is absent or a
|
|
2536
|
+
// tombstone -- is skipped rather than resurrected. Existing chunk rows are
|
|
2537
|
+
// left untouched.
|
|
2538
|
+
let createdChunkBytes = 0;
|
|
2539
|
+
for (const chunk of chunkEntries) {
|
|
2540
|
+
const { rows: parentRows } = await client.query(`SELECT deleted FROM resources
|
|
2541
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`, [spaceId, chunk.collectionId, chunk.resourceId]);
|
|
2542
|
+
const parent = parentRows[0];
|
|
2543
|
+
if (!parent || parent.deleted) {
|
|
2544
|
+
continue;
|
|
2545
|
+
}
|
|
2546
|
+
const { rows: existingChunkRows } = await client.query(`SELECT 1 FROM chunks
|
|
2547
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2548
|
+
AND chunk_index = $4`, [spaceId, chunk.collectionId, chunk.resourceId, chunk.chunkIndex]);
|
|
2549
|
+
if (existingChunkRows.length > 0) {
|
|
2550
|
+
continue;
|
|
2551
|
+
}
|
|
2552
|
+
// The chunk's `version` comes from its archived `.meta.<index>.json`
|
|
2553
|
+
// sidecar; an archive without one (or a chunk written before sidecars)
|
|
2554
|
+
// starts at version 1, the same fresh-write default as a Resource
|
|
2555
|
+
// restored without a sidecar.
|
|
2556
|
+
await client.query(`INSERT INTO chunks (
|
|
2557
|
+
space_id, collection_id, resource_id, chunk_index,
|
|
2558
|
+
content_type, bytes, size, version
|
|
2559
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
|
|
2560
|
+
spaceId,
|
|
2561
|
+
chunk.collectionId,
|
|
2562
|
+
chunk.resourceId,
|
|
2563
|
+
chunk.chunkIndex,
|
|
2564
|
+
chunk.contentType,
|
|
2565
|
+
chunk.body,
|
|
2566
|
+
chunk.body.length,
|
|
2567
|
+
chunk.version ?? 1
|
|
2568
|
+
]);
|
|
2569
|
+
createdChunkBytes += chunk.body.length;
|
|
2570
|
+
}
|
|
2571
|
+
const createdTotalBytes = createdBytes + createdChunkBytes;
|
|
2572
|
+
if (createdTotalBytes > 0) {
|
|
2085
2573
|
// The pre-flight was conservative (it counted skips too), so the
|
|
2086
2574
|
// actual created total always fits; apply it unguarded.
|
|
2087
2575
|
await this._applyUsageDelta({
|
|
2088
2576
|
client,
|
|
2089
2577
|
spaceId,
|
|
2090
|
-
delta:
|
|
2578
|
+
delta: createdTotalBytes
|
|
2091
2579
|
});
|
|
2092
2580
|
}
|
|
2093
2581
|
// Restore the archive's Space-scoped zcap revocations under this
|
|
@@ -2160,5 +2648,113 @@ export class PostgresBackend {
|
|
|
2160
2648
|
sidecar?.epoch ?? null
|
|
2161
2649
|
]);
|
|
2162
2650
|
}
|
|
2651
|
+
/**
|
|
2652
|
+
* Parses a Space-export archive's chunk entries (the `chunked-streams`
|
|
2653
|
+
* feature) out of the raw tar entry map, in the filesystem backend's on-disk
|
|
2654
|
+
* layout so archives round-trip between the two backends. Chunk files live at
|
|
2655
|
+
* `space/<sourceSpaceId>/<collectionId>/.chunks.<encResourceId>/<file>` --
|
|
2656
|
+
* where a chunk is a Resource keyed by its stringified index: an
|
|
2657
|
+
* `r.<index>.<encContentType>.<ext>` representation paired with an optional
|
|
2658
|
+
* `.meta.<index>.json` version sidecar. `buildImportPlan` skips these (it
|
|
2659
|
+
* ignores nested Collection-dir files), so they are parsed here. Ids parsed
|
|
2660
|
+
* from the archive are validated (the path-traversal guard); a file whose
|
|
2661
|
+
* raw index segment is not canonical (`parseChunkIndexSegment`) is ignored,
|
|
2662
|
+
* and a sidecar with no paired representation is dropped (a chunk keeps no
|
|
2663
|
+
* tombstone).
|
|
2664
|
+
* @param entries {Map<string, TarEntry>}
|
|
2665
|
+
* @returns {Array<{ collectionId: string, resourceId: string, chunkIndex:
|
|
2666
|
+
* number, contentType: string, body: Buffer, version?: number }>}
|
|
2667
|
+
*/
|
|
2668
|
+
_chunkEntriesFromArchive(entries) {
|
|
2669
|
+
// Accumulate the representation and the sidecar of each chunk under one
|
|
2670
|
+
// key, then emit only the chunks that carry a representation.
|
|
2671
|
+
const staged = new Map();
|
|
2672
|
+
const pattern = /^space\/[^/]+\/([^/]+)\/(\.chunks\.[^/]+)\/([^/]+)$/;
|
|
2673
|
+
for (const [entryName, entry] of entries) {
|
|
2674
|
+
if (entry.type !== 'file' || !entry.body) {
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
const match = entryName.match(pattern);
|
|
2678
|
+
if (!match) {
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
const collectionId = match[1];
|
|
2682
|
+
const resourceId = parseChunkDirName(match[2]);
|
|
2683
|
+
const fileName = match[3];
|
|
2684
|
+
if (resourceId === undefined) {
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
// Reject a path-traversal / non-URL-safe id parsed from the archive
|
|
2688
|
+
// before it keys a destination row.
|
|
2689
|
+
assertValidId(collectionId, {
|
|
2690
|
+
kind: 'collection',
|
|
2691
|
+
requestName: 'Import Space'
|
|
2692
|
+
});
|
|
2693
|
+
assertValidId(resourceId, {
|
|
2694
|
+
kind: 'resource',
|
|
2695
|
+
requestName: 'Import Space'
|
|
2696
|
+
});
|
|
2697
|
+
// A chunk's index is the "resource id" of its file within the chunk dir:
|
|
2698
|
+
// `r.<index>.<encType>.<ext>` for the representation, `.meta.<index>.json`
|
|
2699
|
+
// for the version sidecar. The RAW index segment must pass the same
|
|
2700
|
+
// canonical predicate as the live route (`parseChunkIndexSegment`), so a
|
|
2701
|
+
// non-canonical spelling (`r.01.*`, `r.%31.*`) is dropped rather than
|
|
2702
|
+
// coerced onto -- and colliding with -- the canonical chunk's row.
|
|
2703
|
+
let chunkIdSegment;
|
|
2704
|
+
let representation;
|
|
2705
|
+
let sidecarVersion;
|
|
2706
|
+
const metaId = metaSidecarFileId(fileName);
|
|
2707
|
+
if (metaId !== undefined) {
|
|
2708
|
+
chunkIdSegment = metaId;
|
|
2709
|
+
try {
|
|
2710
|
+
const sidecar = JSON.parse(entry.body.toString('utf8'));
|
|
2711
|
+
sidecarVersion = sidecar.version;
|
|
2712
|
+
}
|
|
2713
|
+
catch {
|
|
2714
|
+
sidecarVersion = undefined;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
else if (fileName.startsWith('r.')) {
|
|
2718
|
+
const { contentType } = parseResourceFileName(fileName);
|
|
2719
|
+
chunkIdSegment = fileName.split('.')[1];
|
|
2720
|
+
representation = { contentType, body: entry.body };
|
|
2721
|
+
}
|
|
2722
|
+
else {
|
|
2723
|
+
continue;
|
|
2724
|
+
}
|
|
2725
|
+
const chunkIndex = chunkIdSegment === undefined
|
|
2726
|
+
? undefined
|
|
2727
|
+
: parseChunkIndexSegment(chunkIdSegment);
|
|
2728
|
+
if (chunkIndex === undefined) {
|
|
2729
|
+
continue;
|
|
2730
|
+
}
|
|
2731
|
+
const key = `${collectionId}/${resourceId}/${chunkIndex}`;
|
|
2732
|
+
const slot = staged.get(key) ?? { collectionId, resourceId, chunkIndex };
|
|
2733
|
+
if (representation !== undefined) {
|
|
2734
|
+
slot.contentType = representation.contentType;
|
|
2735
|
+
slot.body = representation.body;
|
|
2736
|
+
}
|
|
2737
|
+
if (sidecarVersion !== undefined) {
|
|
2738
|
+
slot.version = sidecarVersion;
|
|
2739
|
+
}
|
|
2740
|
+
staged.set(key, slot);
|
|
2741
|
+
}
|
|
2742
|
+
const parsed = [];
|
|
2743
|
+
for (const slot of staged.values()) {
|
|
2744
|
+
if (slot.body === undefined) {
|
|
2745
|
+
// A sidecar with no paired representation is not a valid chunk.
|
|
2746
|
+
continue;
|
|
2747
|
+
}
|
|
2748
|
+
parsed.push({
|
|
2749
|
+
collectionId: slot.collectionId,
|
|
2750
|
+
resourceId: slot.resourceId,
|
|
2751
|
+
chunkIndex: slot.chunkIndex,
|
|
2752
|
+
contentType: slot.contentType ?? 'application/octet-stream',
|
|
2753
|
+
body: slot.body,
|
|
2754
|
+
version: slot.version
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
return parsed;
|
|
2758
|
+
}
|
|
2163
2759
|
}
|
|
2164
2760
|
//# sourceMappingURL=postgres.js.map
|