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
package/src/backends/postgres.ts
CHANGED
|
@@ -42,9 +42,21 @@ import {
|
|
|
42
42
|
DuplicateRevocationError
|
|
43
43
|
} from '../errors.js'
|
|
44
44
|
import { applyMigrations } from './postgresSchema.js'
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
extractTarEntries,
|
|
47
|
+
buildImportPlan,
|
|
48
|
+
metaSidecarFileId
|
|
49
|
+
} from '../lib/importTar.js'
|
|
50
|
+
import type { TarEntry } from '../lib/importTar.js'
|
|
46
51
|
import { collectionPath, resourcePath } from '../lib/paths.js'
|
|
47
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
fileNameFor,
|
|
54
|
+
parseResourceFileName,
|
|
55
|
+
chunkDirName,
|
|
56
|
+
parseChunkDirName,
|
|
57
|
+
parseChunkIndexSegment
|
|
58
|
+
} from '../lib/resourceFileName.js'
|
|
59
|
+
import { assertValidId } from '../lib/validateId.js'
|
|
48
60
|
import { sanitizeBackendRecord } from '../lib/backends.js'
|
|
49
61
|
import { backendUsageFields } from '../lib/backendUsage.js'
|
|
50
62
|
import { assertEncryptedWriteConforms } from '../lib/encryption.js'
|
|
@@ -206,6 +218,17 @@ function parseSidecar(bytes: Buffer | undefined): SidecarShape | undefined {
|
|
|
206
218
|
}
|
|
207
219
|
}
|
|
208
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The chunk-metadata sidecar shape (`.chunks.<encId>/.meta.<index>.json`) the
|
|
223
|
+
* filesystem backend writes per chunk. Only the monotonic `version` (the
|
|
224
|
+
* chunk's ETag validator) is carried across export/import; the filesystem
|
|
225
|
+
* writes `createdAt` / `updatedAt` too, but this backend's `chunks` table holds
|
|
226
|
+
* no chunk timestamps, so it emits and reads only `version`.
|
|
227
|
+
*/
|
|
228
|
+
interface ChunkSidecar {
|
|
229
|
+
version?: number
|
|
230
|
+
}
|
|
231
|
+
|
|
209
232
|
export class PostgresBackend implements StorageBackend {
|
|
210
233
|
logger: FastifyBaseLogger
|
|
211
234
|
/**
|
|
@@ -421,11 +444,14 @@ export class PostgresBackend implements StorageBackend {
|
|
|
421
444
|
// `key-epochs`: multi-recipient encrypted Collections -- per-epoch wrapped
|
|
422
445
|
// keys on the `encryption` marker, a client-declared `epoch` stamp on
|
|
423
446
|
// Resources, and conditional (`If-Match`) Collection Description writes.
|
|
447
|
+
// `chunked-streams`: chunk addressing at `/{resourceId}/chunks/{n}`, opaque
|
|
448
|
+
// per-chunk raw-bytes storage in the `chunks` table.
|
|
424
449
|
features: [
|
|
425
450
|
'conditional-writes',
|
|
426
451
|
'changes-query',
|
|
427
452
|
'blinded-index-query',
|
|
428
|
-
'key-epochs'
|
|
453
|
+
'key-epochs',
|
|
454
|
+
'chunked-streams'
|
|
429
455
|
]
|
|
430
456
|
}
|
|
431
457
|
}
|
|
@@ -519,6 +545,42 @@ export class PostgresBackend implements StorageBackend {
|
|
|
519
545
|
}
|
|
520
546
|
}
|
|
521
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Serializes concurrent CREATORS of one not-yet-existing row (a Resource or
|
|
550
|
+
* a chunk) on a transaction-scoped advisory lock keyed by the row's
|
|
551
|
+
* identity. Under READ COMMITTED a `SELECT ... FOR UPDATE` on an absent row
|
|
552
|
+
* locks nothing (no gap locks), so two concurrent creators would both read
|
|
553
|
+
* "no prior row" and both apply their full byte size as the usage delta --
|
|
554
|
+
* and while the `ON CONFLICT` arm recomputes `version` from the conflicting
|
|
555
|
+
* row, it cannot retroactively fix the loser's delta, leaving `usage_bytes`
|
|
556
|
+
* inflated by one write's size. The caller takes this lock when its
|
|
557
|
+
* lock-nothing SELECT found no row, then RE-reads the row: the second
|
|
558
|
+
* creator blocks here until the first commits, and its re-read sees the
|
|
559
|
+
* committed row, so its precondition, version, and usage delta are computed
|
|
560
|
+
* from accurate state. Held to commit (advisory xact lock). The `create:`
|
|
561
|
+
* prefix keeps this key domain distinct from the unique-blinded-term
|
|
562
|
+
* advisory lock, which hashes the bare `(spaceId, collectionId)`.
|
|
563
|
+
* @param options {object}
|
|
564
|
+
* @param options.client {pg.PoolClient}
|
|
565
|
+
* @param options.spaceId {string}
|
|
566
|
+
* @param options.rowKey {string} the row's identity within the Space
|
|
567
|
+
* @returns {Promise<void>}
|
|
568
|
+
*/
|
|
569
|
+
private async _lockSameKeyCreate({
|
|
570
|
+
client,
|
|
571
|
+
spaceId,
|
|
572
|
+
rowKey
|
|
573
|
+
}: {
|
|
574
|
+
client: pg.PoolClient
|
|
575
|
+
spaceId: string
|
|
576
|
+
rowKey: string
|
|
577
|
+
}): Promise<void> {
|
|
578
|
+
await client.query(
|
|
579
|
+
'SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))',
|
|
580
|
+
[spaceId, `create:${rowKey}`]
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
522
584
|
// Quotas
|
|
523
585
|
|
|
524
586
|
/**
|
|
@@ -546,13 +608,20 @@ export class PostgresBackend implements StorageBackend {
|
|
|
546
608
|
|
|
547
609
|
let usageByCollection: CollectionUsage[] | undefined
|
|
548
610
|
if (includeCollections) {
|
|
611
|
+
// Per-Collection usage sums both Resource content bytes and chunk bytes
|
|
612
|
+
// (the `chunked-streams` feature) so the breakdown agrees with the
|
|
613
|
+
// Space total in the transactional counter.
|
|
549
614
|
const { rows: collectionRows } = await this._pool.query<{
|
|
550
615
|
collection_id: string
|
|
551
616
|
usage: string
|
|
552
617
|
}>(
|
|
553
|
-
`SELECT collection_id, COALESCE(SUM(
|
|
554
|
-
FROM resources
|
|
555
|
-
|
|
618
|
+
`SELECT collection_id, COALESCE(SUM(bytes), 0) AS usage FROM (
|
|
619
|
+
SELECT collection_id, size_bytes AS bytes FROM resources
|
|
620
|
+
WHERE space_id = $1
|
|
621
|
+
UNION ALL
|
|
622
|
+
SELECT collection_id, size AS bytes FROM chunks
|
|
623
|
+
WHERE space_id = $1
|
|
624
|
+
) usage_rows
|
|
556
625
|
GROUP BY collection_id
|
|
557
626
|
ORDER BY collection_id`,
|
|
558
627
|
[spaceId]
|
|
@@ -595,6 +664,8 @@ export class PostgresBackend implements StorageBackend {
|
|
|
595
664
|
(SELECT COALESCE(usage_bytes, 0) FROM spaces WHERE space_id = $1)
|
|
596
665
|
AS space_total,
|
|
597
666
|
(SELECT COALESCE(SUM(size_bytes), 0) FROM resources
|
|
667
|
+
WHERE space_id = $1 AND collection_id = $2)
|
|
668
|
+
+ (SELECT COALESCE(SUM(size), 0) FROM chunks
|
|
598
669
|
WHERE space_id = $1 AND collection_id = $2) AS collection_total`,
|
|
599
670
|
[spaceId, collectionId]
|
|
600
671
|
)
|
|
@@ -1024,9 +1095,15 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1024
1095
|
collectionId: string
|
|
1025
1096
|
}): Promise<void> {
|
|
1026
1097
|
await this._withTransaction(async client => {
|
|
1098
|
+
// The Collection's freed bytes are its Resource content plus its chunk
|
|
1099
|
+
// bytes (the `chunked-streams` feature); both cascade away with the
|
|
1100
|
+
// Collection row, so both leave the quota counter.
|
|
1027
1101
|
const { rows } = await client.query<{ total: string }>(
|
|
1028
|
-
`SELECT
|
|
1029
|
-
|
|
1102
|
+
`SELECT
|
|
1103
|
+
(SELECT COALESCE(SUM(size_bytes), 0) FROM resources
|
|
1104
|
+
WHERE space_id = $1 AND collection_id = $2)
|
|
1105
|
+
+ (SELECT COALESCE(SUM(size), 0) FROM chunks
|
|
1106
|
+
WHERE space_id = $1 AND collection_id = $2) AS total`,
|
|
1030
1107
|
[spaceId, collectionId]
|
|
1031
1108
|
)
|
|
1032
1109
|
const freedBytes = Number(rows[0]?.total ?? 0)
|
|
@@ -1307,7 +1384,31 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1307
1384
|
FOR UPDATE`,
|
|
1308
1385
|
[spaceId, collectionId, resourceId]
|
|
1309
1386
|
)
|
|
1310
|
-
|
|
1387
|
+
let prior = rows[0]
|
|
1388
|
+
if (prior === undefined) {
|
|
1389
|
+
// The lock-nothing case: serialize with any concurrent creator of the
|
|
1390
|
+
// same id and re-read, so `exists` / `priorSize` below reflect the row
|
|
1391
|
+
// it committed (see `_lockSameKeyCreate` -- without this, both
|
|
1392
|
+
// creators would count their full byte size as the usage delta).
|
|
1393
|
+
await this._lockSameKeyCreate({
|
|
1394
|
+
client,
|
|
1395
|
+
spaceId,
|
|
1396
|
+
rowKey: `${collectionId}/${resourceId}`
|
|
1397
|
+
})
|
|
1398
|
+
const { rows: reread } = await client.query<
|
|
1399
|
+
Pick<
|
|
1400
|
+
ResourceRow,
|
|
1401
|
+
'version' | 'size_bytes' | 'deleted' | 'created_at' | 'created_by'
|
|
1402
|
+
>
|
|
1403
|
+
>(
|
|
1404
|
+
`SELECT version, size_bytes, deleted, created_at, created_by
|
|
1405
|
+
FROM resources
|
|
1406
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1407
|
+
FOR UPDATE`,
|
|
1408
|
+
[spaceId, collectionId, resourceId]
|
|
1409
|
+
)
|
|
1410
|
+
prior = reread[0]
|
|
1411
|
+
}
|
|
1311
1412
|
const exists = prior !== undefined && !prior.deleted
|
|
1312
1413
|
if (ifMatch !== undefined || ifNoneMatch) {
|
|
1313
1414
|
assertWritePrecondition({
|
|
@@ -1339,7 +1440,7 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1339
1440
|
|
|
1340
1441
|
const now = new Date().toISOString()
|
|
1341
1442
|
const version = (prior?.version ?? 0) + 1
|
|
1342
|
-
const priorSize = exists ? Number(prior
|
|
1443
|
+
const priorSize = exists ? Number(prior?.size_bytes ?? 0) : 0
|
|
1343
1444
|
const delta = content.length - priorSize
|
|
1344
1445
|
if (delta !== 0) {
|
|
1345
1446
|
await this._applyUsageDelta({ client, spaceId, delta })
|
|
@@ -1350,10 +1451,11 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1350
1451
|
// dropped both (the metadata went with the deleted Resource).
|
|
1351
1452
|
//
|
|
1352
1453
|
// Create-if-absent atomicity: when `If-None-Match: *` found NO prior row
|
|
1353
|
-
// (a tombstone is a real row and stays lock-serialized),
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1454
|
+
// (a tombstone is a real row and stays lock-serialized), concurrent
|
|
1455
|
+
// creators through this method are already serialized by
|
|
1456
|
+
// `_lockSameKeyCreate` above -- but a writer that does not take that
|
|
1457
|
+
// lock (`importSpace`'s plain INSERTs) can still race. A plain INSERT
|
|
1458
|
+
// (no ON CONFLICT) keeps the primary key as the arbiter: the loser's
|
|
1357
1459
|
// unique violation maps to the 412 the precondition would have thrown.
|
|
1358
1460
|
// `createdBy` names the Resource's creator, not its last writer: taken
|
|
1359
1461
|
// from this write's invoker only when there is no prior row at all
|
|
@@ -1519,7 +1621,25 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1519
1621
|
// no-op, keeping an existing tombstone's change-feed entry stable.
|
|
1520
1622
|
return
|
|
1521
1623
|
}
|
|
1522
|
-
|
|
1624
|
+
// A soft delete is an UPDATE, not a row removal, so the chunk foreign
|
|
1625
|
+
// key's ON DELETE CASCADE does not fire -- remove the Resource's chunks
|
|
1626
|
+
// (the `chunked-streams` feature) explicitly in this same transaction so
|
|
1627
|
+
// they never outlive their parent, and return their bytes to the quota
|
|
1628
|
+
// counter alongside the Resource's content bytes.
|
|
1629
|
+
const { rows: chunkRows } = await client.query<{ total: string }>(
|
|
1630
|
+
`SELECT COALESCE(SUM(size), 0) AS total FROM chunks
|
|
1631
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
|
|
1632
|
+
[spaceId, collectionId, resourceId]
|
|
1633
|
+
)
|
|
1634
|
+
const freedChunkBytes = Number(chunkRows[0]?.total ?? 0)
|
|
1635
|
+
if (freedChunkBytes > 0) {
|
|
1636
|
+
await client.query(
|
|
1637
|
+
`DELETE FROM chunks
|
|
1638
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
|
|
1639
|
+
[spaceId, collectionId, resourceId]
|
|
1640
|
+
)
|
|
1641
|
+
}
|
|
1642
|
+
const freedBytes = Number(prior.size_bytes) + freedChunkBytes
|
|
1523
1643
|
if (freedBytes > 0) {
|
|
1524
1644
|
await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
|
|
1525
1645
|
}
|
|
@@ -1671,6 +1791,395 @@ export class PostgresBackend implements StorageBackend {
|
|
|
1671
1791
|
})
|
|
1672
1792
|
}
|
|
1673
1793
|
|
|
1794
|
+
// Chunks (the `chunked-streams` feature)
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* Writes one chunk of a chunked Resource as one transaction: the parent
|
|
1798
|
+
* Resource must exist (checked atomically -- a `FOR SHARE` lock on it also
|
|
1799
|
+
* blocks a concurrent delete of the parent for the duration of the write, so
|
|
1800
|
+
* a chunk can never be orphaned by a racing `deleteResource`), the chunk row
|
|
1801
|
+
* is locked, its precondition evaluated, its monotonic `version` bumped, and
|
|
1802
|
+
* the transactional quota delta applied. The chunk body is stored opaquely as
|
|
1803
|
+
* a single `bytea`, the buffered-blob path (bounded by `maxUploadBytes`),
|
|
1804
|
+
* exactly like a binary Resource representation.
|
|
1805
|
+
* @param options {object}
|
|
1806
|
+
* @param options.spaceId {string}
|
|
1807
|
+
* @param options.collectionId {string}
|
|
1808
|
+
* @param options.resourceId {string}
|
|
1809
|
+
* @param options.chunkIndex {number} a non-negative safe integer
|
|
1810
|
+
* @param options.input {ResourceInput}
|
|
1811
|
+
* @param [options.ifMatch] {string}
|
|
1812
|
+
* @param [options.ifNoneMatch] {boolean}
|
|
1813
|
+
* @returns {Promise<{ version: number }>} the chunk's new version
|
|
1814
|
+
*/
|
|
1815
|
+
async writeChunk({
|
|
1816
|
+
spaceId,
|
|
1817
|
+
collectionId,
|
|
1818
|
+
resourceId,
|
|
1819
|
+
chunkIndex,
|
|
1820
|
+
input,
|
|
1821
|
+
ifMatch,
|
|
1822
|
+
ifNoneMatch
|
|
1823
|
+
}: {
|
|
1824
|
+
spaceId: string
|
|
1825
|
+
collectionId: string
|
|
1826
|
+
resourceId: string
|
|
1827
|
+
chunkIndex: number
|
|
1828
|
+
input: ResourceInput
|
|
1829
|
+
ifMatch?: string
|
|
1830
|
+
ifNoneMatch?: boolean
|
|
1831
|
+
}): Promise<{ version: number }> {
|
|
1832
|
+
const { maxUploadBytes } = this
|
|
1833
|
+
let bytes: Buffer
|
|
1834
|
+
if (input.kind === 'json') {
|
|
1835
|
+
bytes = Buffer.from(JSON.stringify(input.data))
|
|
1836
|
+
if (bytes.length > maxUploadBytes) {
|
|
1837
|
+
throw new PayloadTooLargeError({
|
|
1838
|
+
maxUploadBytes,
|
|
1839
|
+
backendId: this.describe().id,
|
|
1840
|
+
uploadBytes: bytes.length
|
|
1841
|
+
})
|
|
1842
|
+
}
|
|
1843
|
+
} else {
|
|
1844
|
+
// Pre-flight a declared size, then buffer through the counting guard that
|
|
1845
|
+
// hard-caps a body whose size is omitted or understated. Buffering
|
|
1846
|
+
// happens BEFORE the transaction so a slow upload holds no row lock.
|
|
1847
|
+
if (
|
|
1848
|
+
input.declaredBytes !== undefined &&
|
|
1849
|
+
input.declaredBytes > maxUploadBytes
|
|
1850
|
+
) {
|
|
1851
|
+
throw new PayloadTooLargeError({
|
|
1852
|
+
maxUploadBytes,
|
|
1853
|
+
backendId: this.describe().id,
|
|
1854
|
+
uploadBytes: input.declaredBytes
|
|
1855
|
+
})
|
|
1856
|
+
}
|
|
1857
|
+
bytes = await bufferStreamCapped({
|
|
1858
|
+
stream: input.stream,
|
|
1859
|
+
maxUploadBytes,
|
|
1860
|
+
backendId: this.describe().id
|
|
1861
|
+
})
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
return this._withTransaction(async client => {
|
|
1865
|
+
// Parent Resource must exist (and not be a tombstone). `FOR SHARE`
|
|
1866
|
+
// conflicts with the `FOR UPDATE` a concurrent `deleteResource` takes, so
|
|
1867
|
+
// the two serialize on the parent row -- the parent cannot be deleted
|
|
1868
|
+
// between this check and the chunk write.
|
|
1869
|
+
const { rows: parentRows } = await client.query<{ deleted: boolean }>(
|
|
1870
|
+
`SELECT deleted FROM resources
|
|
1871
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1872
|
+
FOR SHARE`,
|
|
1873
|
+
[spaceId, collectionId, resourceId]
|
|
1874
|
+
)
|
|
1875
|
+
const parent = parentRows[0]
|
|
1876
|
+
if (!parent || parent.deleted) {
|
|
1877
|
+
throw new ResourceNotFoundError({ requestName: 'Write Chunk' })
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Lock the chunk row (if any) and read its current version/size, so the
|
|
1881
|
+
// precondition, the monotonic bump, and the usage delta are all atomic
|
|
1882
|
+
// with the write.
|
|
1883
|
+
const { rows } = await client.query<{ version: number; size: string }>(
|
|
1884
|
+
`SELECT version, size FROM chunks
|
|
1885
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1886
|
+
AND chunk_index = $4
|
|
1887
|
+
FOR UPDATE`,
|
|
1888
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
1889
|
+
)
|
|
1890
|
+
let prior = rows[0]
|
|
1891
|
+
const chunkLabel = `${resourceId}/chunks/${chunkIndex}`
|
|
1892
|
+
if (prior === undefined) {
|
|
1893
|
+
// The lock-nothing case: serialize with any concurrent creator of the
|
|
1894
|
+
// same chunk and re-read, so the usage delta below reflects the row it
|
|
1895
|
+
// committed (see `_lockSameKeyCreate`).
|
|
1896
|
+
await this._lockSameKeyCreate({
|
|
1897
|
+
client,
|
|
1898
|
+
spaceId,
|
|
1899
|
+
rowKey: `${collectionId}/${chunkLabel}`
|
|
1900
|
+
})
|
|
1901
|
+
const { rows: reread } = await client.query<{
|
|
1902
|
+
version: number
|
|
1903
|
+
size: string
|
|
1904
|
+
}>(
|
|
1905
|
+
`SELECT version, size FROM chunks
|
|
1906
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
1907
|
+
AND chunk_index = $4
|
|
1908
|
+
FOR UPDATE`,
|
|
1909
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
1910
|
+
)
|
|
1911
|
+
prior = reread[0]
|
|
1912
|
+
}
|
|
1913
|
+
const exists = prior !== undefined
|
|
1914
|
+
if (ifMatch !== undefined || ifNoneMatch) {
|
|
1915
|
+
assertWritePrecondition({
|
|
1916
|
+
resourceId: chunkLabel,
|
|
1917
|
+
exists,
|
|
1918
|
+
currentVersion: prior?.version ?? 0,
|
|
1919
|
+
ifMatch,
|
|
1920
|
+
ifNoneMatch
|
|
1921
|
+
})
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const version = (prior?.version ?? 0) + 1
|
|
1925
|
+
const priorSize = exists ? Number(prior?.size ?? 0) : 0
|
|
1926
|
+
const delta = bytes.length - priorSize
|
|
1927
|
+
if (delta !== 0) {
|
|
1928
|
+
await this._applyUsageDelta({ client, spaceId, delta })
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const values = [
|
|
1932
|
+
spaceId,
|
|
1933
|
+
collectionId,
|
|
1934
|
+
resourceId,
|
|
1935
|
+
chunkIndex,
|
|
1936
|
+
input.contentType,
|
|
1937
|
+
bytes,
|
|
1938
|
+
bytes.length,
|
|
1939
|
+
version
|
|
1940
|
+
]
|
|
1941
|
+
const insertSql = `
|
|
1942
|
+
INSERT INTO chunks (
|
|
1943
|
+
space_id, collection_id, resource_id, chunk_index,
|
|
1944
|
+
content_type, bytes, size, version
|
|
1945
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`
|
|
1946
|
+
// Create-if-absent atomicity mirrors `writeResource`: concurrent
|
|
1947
|
+
// creators through this method are serialized by `_lockSameKeyCreate`
|
|
1948
|
+
// above, and against a writer that does not take that lock
|
|
1949
|
+
// (`importSpace`'s plain INSERTs) the plain INSERT keeps the primary key
|
|
1950
|
+
// as the arbiter -- a racing creator's unique violation maps to the 412
|
|
1951
|
+
// the precondition would have thrown.
|
|
1952
|
+
if (ifNoneMatch && prior === undefined) {
|
|
1953
|
+
try {
|
|
1954
|
+
await client.query(insertSql, values)
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
if ((err as { code?: string }).code === '23505') {
|
|
1957
|
+
throw new PreconditionFailedError({
|
|
1958
|
+
detail: `Chunk '${chunkLabel}' already exists (If-None-Match: *).`
|
|
1959
|
+
})
|
|
1960
|
+
}
|
|
1961
|
+
throw err
|
|
1962
|
+
}
|
|
1963
|
+
return { version }
|
|
1964
|
+
}
|
|
1965
|
+
// The conflict update derives `version` from the row (`chunks.version +
|
|
1966
|
+
// 1`), not from the pre-read, so a concurrent creator that slipped in
|
|
1967
|
+
// after our lock-nothing SELECT still advances the counter monotonically.
|
|
1968
|
+
// RETURNING reports the version that actually landed.
|
|
1969
|
+
const { rows: written } = await client.query<{ version: number }>(
|
|
1970
|
+
`${insertSql}
|
|
1971
|
+
ON CONFLICT (space_id, collection_id, resource_id, chunk_index)
|
|
1972
|
+
DO UPDATE SET
|
|
1973
|
+
content_type = EXCLUDED.content_type,
|
|
1974
|
+
bytes = EXCLUDED.bytes,
|
|
1975
|
+
size = EXCLUDED.size,
|
|
1976
|
+
version = chunks.version + 1
|
|
1977
|
+
RETURNING version`,
|
|
1978
|
+
values
|
|
1979
|
+
)
|
|
1980
|
+
return { version: written[0]!.version }
|
|
1981
|
+
})
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* Reads a chunk's bytes. Rejects with `ResourceNotFoundError` (404) when no
|
|
1986
|
+
* chunk is stored at that index.
|
|
1987
|
+
* @param options {object}
|
|
1988
|
+
* @param options.spaceId {string}
|
|
1989
|
+
* @param options.collectionId {string}
|
|
1990
|
+
* @param options.resourceId {string}
|
|
1991
|
+
* @param options.chunkIndex {number}
|
|
1992
|
+
* @returns {Promise<ResourceResult>}
|
|
1993
|
+
*/
|
|
1994
|
+
async getChunk({
|
|
1995
|
+
spaceId,
|
|
1996
|
+
collectionId,
|
|
1997
|
+
resourceId,
|
|
1998
|
+
chunkIndex
|
|
1999
|
+
}: {
|
|
2000
|
+
spaceId: string
|
|
2001
|
+
collectionId: string
|
|
2002
|
+
resourceId: string
|
|
2003
|
+
chunkIndex: number
|
|
2004
|
+
}): Promise<ResourceResult> {
|
|
2005
|
+
const { rows } = await this._pool.query<{
|
|
2006
|
+
content_type: string
|
|
2007
|
+
bytes: Buffer
|
|
2008
|
+
version: number
|
|
2009
|
+
}>(
|
|
2010
|
+
`SELECT content_type, bytes, version FROM chunks
|
|
2011
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2012
|
+
AND chunk_index = $4`,
|
|
2013
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
2014
|
+
)
|
|
2015
|
+
const row = rows[0]
|
|
2016
|
+
if (!row) {
|
|
2017
|
+
throw new ResourceNotFoundError({ requestName: 'Get Chunk' })
|
|
2018
|
+
}
|
|
2019
|
+
return {
|
|
2020
|
+
resourceStream: Readable.from(row.bytes),
|
|
2021
|
+
storedResourceType: row.content_type,
|
|
2022
|
+
version: row.version
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
/**
|
|
2027
|
+
* Reads a chunk's stored content-type / size / version (the HEAD payload
|
|
2028
|
+
* headers). Resolves `undefined` when the chunk is absent.
|
|
2029
|
+
* @param options {object}
|
|
2030
|
+
* @param options.spaceId {string}
|
|
2031
|
+
* @param options.collectionId {string}
|
|
2032
|
+
* @param options.resourceId {string}
|
|
2033
|
+
* @param options.chunkIndex {number}
|
|
2034
|
+
* @returns {Promise<{ contentType: string, size: number, version?: number }
|
|
2035
|
+
* | undefined>}
|
|
2036
|
+
*/
|
|
2037
|
+
async getChunkMetadata({
|
|
2038
|
+
spaceId,
|
|
2039
|
+
collectionId,
|
|
2040
|
+
resourceId,
|
|
2041
|
+
chunkIndex
|
|
2042
|
+
}: {
|
|
2043
|
+
spaceId: string
|
|
2044
|
+
collectionId: string
|
|
2045
|
+
resourceId: string
|
|
2046
|
+
chunkIndex: number
|
|
2047
|
+
}): Promise<
|
|
2048
|
+
{ contentType: string; size: number; version?: number } | undefined
|
|
2049
|
+
> {
|
|
2050
|
+
const { rows } = await this._pool.query<{
|
|
2051
|
+
content_type: string
|
|
2052
|
+
size: string
|
|
2053
|
+
version: number
|
|
2054
|
+
}>(
|
|
2055
|
+
`SELECT content_type, size, version FROM chunks
|
|
2056
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2057
|
+
AND chunk_index = $4`,
|
|
2058
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
2059
|
+
)
|
|
2060
|
+
const row = rows[0]
|
|
2061
|
+
if (!row) {
|
|
2062
|
+
return undefined
|
|
2063
|
+
}
|
|
2064
|
+
return {
|
|
2065
|
+
contentType: row.content_type,
|
|
2066
|
+
size: Number(row.size),
|
|
2067
|
+
version: row.version
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
/**
|
|
2072
|
+
* Deletes one chunk as one transaction (the chunk row is locked, its
|
|
2073
|
+
* `ifMatch` precondition evaluated atomically, and its bytes returned to the
|
|
2074
|
+
* quota counter). Resolves `true` when a chunk was removed, `false` when none
|
|
2075
|
+
* was stored at that index (the handler 404s on `false` -- chunk deletes are
|
|
2076
|
+
* not silently idempotent, unlike `deleteResource`).
|
|
2077
|
+
* @param options {object}
|
|
2078
|
+
* @param options.spaceId {string}
|
|
2079
|
+
* @param options.collectionId {string}
|
|
2080
|
+
* @param options.resourceId {string}
|
|
2081
|
+
* @param options.chunkIndex {number}
|
|
2082
|
+
* @param [options.ifMatch] {string}
|
|
2083
|
+
* @returns {Promise<boolean>}
|
|
2084
|
+
*/
|
|
2085
|
+
async deleteChunk({
|
|
2086
|
+
spaceId,
|
|
2087
|
+
collectionId,
|
|
2088
|
+
resourceId,
|
|
2089
|
+
chunkIndex,
|
|
2090
|
+
ifMatch
|
|
2091
|
+
}: {
|
|
2092
|
+
spaceId: string
|
|
2093
|
+
collectionId: string
|
|
2094
|
+
resourceId: string
|
|
2095
|
+
chunkIndex: number
|
|
2096
|
+
ifMatch?: string
|
|
2097
|
+
}): Promise<boolean> {
|
|
2098
|
+
return this._withTransaction(async client => {
|
|
2099
|
+
const { rows } = await client.query<{ version: number; size: string }>(
|
|
2100
|
+
`SELECT version, size FROM chunks
|
|
2101
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2102
|
+
AND chunk_index = $4
|
|
2103
|
+
FOR UPDATE`,
|
|
2104
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
2105
|
+
)
|
|
2106
|
+
const prior = rows[0]
|
|
2107
|
+
if (prior === undefined) {
|
|
2108
|
+
return false
|
|
2109
|
+
}
|
|
2110
|
+
if (ifMatch !== undefined) {
|
|
2111
|
+
assertWritePrecondition({
|
|
2112
|
+
resourceId: `${resourceId}/chunks/${chunkIndex}`,
|
|
2113
|
+
exists: true,
|
|
2114
|
+
currentVersion: prior.version,
|
|
2115
|
+
ifMatch
|
|
2116
|
+
})
|
|
2117
|
+
}
|
|
2118
|
+
await client.query(
|
|
2119
|
+
`DELETE FROM chunks
|
|
2120
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2121
|
+
AND chunk_index = $4`,
|
|
2122
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
2123
|
+
)
|
|
2124
|
+
const freedBytes = Number(prior.size)
|
|
2125
|
+
if (freedBytes > 0) {
|
|
2126
|
+
await this._applyUsageDelta({ client, spaceId, delta: -freedBytes })
|
|
2127
|
+
}
|
|
2128
|
+
return true
|
|
2129
|
+
})
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Lists a Resource's stored chunks in ascending `chunk_index` order -- the
|
|
2134
|
+
* discovery/reassembly listing. The opaque `bytes` column is deliberately not
|
|
2135
|
+
* selected. An empty listing (Resource with no chunks, or an absent Resource)
|
|
2136
|
+
* resolves `{ count: 0, chunks: [] }`.
|
|
2137
|
+
* @param options {object}
|
|
2138
|
+
* @param options.spaceId {string}
|
|
2139
|
+
* @param options.collectionId {string}
|
|
2140
|
+
* @param options.resourceId {string}
|
|
2141
|
+
* @returns {Promise<{ count: number, chunks: Array<{ index: number, size:
|
|
2142
|
+
* number, contentType: string, version?: number }> }>}
|
|
2143
|
+
*/
|
|
2144
|
+
async listChunks({
|
|
2145
|
+
spaceId,
|
|
2146
|
+
collectionId,
|
|
2147
|
+
resourceId
|
|
2148
|
+
}: {
|
|
2149
|
+
spaceId: string
|
|
2150
|
+
collectionId: string
|
|
2151
|
+
resourceId: string
|
|
2152
|
+
}): Promise<{
|
|
2153
|
+
count: number
|
|
2154
|
+
chunks: Array<{
|
|
2155
|
+
index: number
|
|
2156
|
+
size: number
|
|
2157
|
+
contentType: string
|
|
2158
|
+
version?: number
|
|
2159
|
+
}>
|
|
2160
|
+
}> {
|
|
2161
|
+
const { rows } = await this._pool.query<{
|
|
2162
|
+
chunk_index: number
|
|
2163
|
+
size: string
|
|
2164
|
+
content_type: string
|
|
2165
|
+
version: number
|
|
2166
|
+
}>(
|
|
2167
|
+
`SELECT chunk_index, size, content_type, version FROM chunks
|
|
2168
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
2169
|
+
ORDER BY chunk_index`,
|
|
2170
|
+
[spaceId, collectionId, resourceId]
|
|
2171
|
+
)
|
|
2172
|
+
return {
|
|
2173
|
+
count: rows.length,
|
|
2174
|
+
chunks: rows.map(row => ({
|
|
2175
|
+
index: row.chunk_index,
|
|
2176
|
+
size: Number(row.size),
|
|
2177
|
+
contentType: row.content_type,
|
|
2178
|
+
version: row.version
|
|
2179
|
+
}))
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
1674
2183
|
/**
|
|
1675
2184
|
* Replication change feed (the `changes` query profile): one indexed keyset
|
|
1676
2185
|
* query over `(updatedAt, resourceId)`, tombstones included, JSON documents
|
|
@@ -2435,7 +2944,8 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2435
2944
|
{ rows: policyRows },
|
|
2436
2945
|
{ rows: collectionRows },
|
|
2437
2946
|
{ rows: resourceRows },
|
|
2438
|
-
{ rows: revocationRows }
|
|
2947
|
+
{ rows: revocationRows },
|
|
2948
|
+
{ rows: chunkRows }
|
|
2439
2949
|
] = await Promise.all([
|
|
2440
2950
|
this._pool.query<{
|
|
2441
2951
|
collection_id: string
|
|
@@ -2477,6 +2987,20 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2477
2987
|
`SELECT delegator, capability_id, record FROM space_revocations
|
|
2478
2988
|
WHERE space_id = $1`,
|
|
2479
2989
|
[spaceId]
|
|
2990
|
+
),
|
|
2991
|
+
// Chunk metadata only -- bytes are fetched one chunk at a time while
|
|
2992
|
+
// packing, so an export never holds a chunked Resource whole in memory.
|
|
2993
|
+
this._pool.query<{
|
|
2994
|
+
collection_id: string
|
|
2995
|
+
resource_id: string
|
|
2996
|
+
chunk_index: number
|
|
2997
|
+
content_type: string
|
|
2998
|
+
version: number
|
|
2999
|
+
}>(
|
|
3000
|
+
`SELECT collection_id, resource_id, chunk_index, content_type, version
|
|
3001
|
+
FROM chunks WHERE space_id = $1
|
|
3002
|
+
ORDER BY collection_id, resource_id, chunk_index`,
|
|
3003
|
+
[spaceId]
|
|
2480
3004
|
)
|
|
2481
3005
|
])
|
|
2482
3006
|
|
|
@@ -2487,11 +3011,26 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2487
3011
|
row => row.collection_id === '' && row.resource_id === ''
|
|
2488
3012
|
)?.policy
|
|
2489
3013
|
|
|
2490
|
-
// A file entry carries its bytes inline (the small JSON dot-files)
|
|
2491
|
-
//
|
|
3014
|
+
// A file entry carries its bytes inline (the small JSON dot-files), a lazy
|
|
3015
|
+
// reference to a resource representation, or a chunk directory (the
|
|
3016
|
+
// `chunked-streams` feature) whose files are likewise inline (the per-chunk
|
|
3017
|
+
// `.meta.<index>.json` sidecar) or lazy (the chunk `r.<index>...`
|
|
3018
|
+
// representation, resolved at pack time). `name` is the entry's sort key
|
|
3019
|
+
// within its dir; a chunk directory sorts by its `.chunks.<encId>` dir name.
|
|
3020
|
+
type ChunkFile =
|
|
3021
|
+
| { name: string; bytes: Buffer }
|
|
3022
|
+
| {
|
|
3023
|
+
name: string
|
|
3024
|
+
chunk: {
|
|
3025
|
+
collectionId: string
|
|
3026
|
+
resourceId: string
|
|
3027
|
+
chunkIndex: number
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
2492
3030
|
type FileEntry =
|
|
2493
3031
|
| { name: string; bytes: Buffer }
|
|
2494
3032
|
| { name: string; resource: { collectionId: string; resourceId: string } }
|
|
3033
|
+
| { name: string; chunkDir: ChunkFile[] }
|
|
2495
3034
|
// Space-level dot-files are always small JSON, carried inline.
|
|
2496
3035
|
const spaceFiles: Array<{ name: string; bytes: Buffer }> = [
|
|
2497
3036
|
{
|
|
@@ -2564,6 +3103,48 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2564
3103
|
})
|
|
2565
3104
|
}
|
|
2566
3105
|
}
|
|
3106
|
+
// Chunks (the `chunked-streams` feature): each chunked Resource contributes
|
|
3107
|
+
// one `.chunks.<encResourceId>/` subdirectory in its Collection dir, in the
|
|
3108
|
+
// exact filesystem-backend layout so an archive imports into either backend.
|
|
3109
|
+
// A chunk is stored there as a Resource keyed by its stringified index: an
|
|
3110
|
+
// `r.<index>.<encContentType>.<ext>` representation file (`fileNameFor`)
|
|
3111
|
+
// plus a `.meta.<index>.json` version sidecar. Files within a chunk dir are
|
|
3112
|
+
// sorted by name (the filesystem's readdir sort). Rows arrive ordered by
|
|
3113
|
+
// `(collection, resource, index)`.
|
|
3114
|
+
const chunkDirsByResource = new Map<string, ChunkFile[]>()
|
|
3115
|
+
for (const row of chunkRows) {
|
|
3116
|
+
const dirKey = `${row.collection_id}/${row.resource_id}`
|
|
3117
|
+
let chunkFiles = chunkDirsByResource.get(dirKey)
|
|
3118
|
+
if (!chunkFiles) {
|
|
3119
|
+
chunkFiles = []
|
|
3120
|
+
chunkDirsByResource.set(dirKey, chunkFiles)
|
|
3121
|
+
filesFor(row.collection_id).push({
|
|
3122
|
+
name: chunkDirName(row.resource_id),
|
|
3123
|
+
chunkDir: chunkFiles
|
|
3124
|
+
})
|
|
3125
|
+
}
|
|
3126
|
+
const chunkId = String(row.chunk_index)
|
|
3127
|
+
chunkFiles.push({
|
|
3128
|
+
name: fileNameFor({
|
|
3129
|
+
resourceId: chunkId,
|
|
3130
|
+
contentType: row.content_type
|
|
3131
|
+
}),
|
|
3132
|
+
chunk: {
|
|
3133
|
+
collectionId: row.collection_id,
|
|
3134
|
+
resourceId: row.resource_id,
|
|
3135
|
+
chunkIndex: row.chunk_index
|
|
3136
|
+
}
|
|
3137
|
+
})
|
|
3138
|
+
chunkFiles.push({
|
|
3139
|
+
name: `.meta.${chunkId}.json`,
|
|
3140
|
+
bytes: Buffer.from(
|
|
3141
|
+
JSON.stringify({ version: row.version } satisfies ChunkSidecar)
|
|
3142
|
+
)
|
|
3143
|
+
})
|
|
3144
|
+
}
|
|
3145
|
+
for (const chunkFiles of chunkDirsByResource.values()) {
|
|
3146
|
+
chunkFiles.sort((left, right) => left.name.localeCompare(right.name))
|
|
3147
|
+
}
|
|
2567
3148
|
|
|
2568
3149
|
// Top-level order: space-level files and collection dirs interleaved,
|
|
2569
3150
|
// sorted by name -- the same order the filesystem's readdir+sort yields.
|
|
@@ -2597,7 +3178,17 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2597
3178
|
spaceId,
|
|
2598
3179
|
entries: topLevel.map(entry =>
|
|
2599
3180
|
entry.kind === 'collection'
|
|
2600
|
-
? {
|
|
3181
|
+
? {
|
|
3182
|
+
name: entry.name,
|
|
3183
|
+
// A chunk directory expands to its `.chunks.<encId>/<file>`
|
|
3184
|
+
// relative paths (mirroring the pack order), so the manifest
|
|
3185
|
+
// matches the filesystem backend's for the same Space.
|
|
3186
|
+
files: entry.files.flatMap(file =>
|
|
3187
|
+
'chunkDir' in file
|
|
3188
|
+
? file.chunkDir.map(child => `${file.name}/${child.name}`)
|
|
3189
|
+
: [file.name]
|
|
3190
|
+
)
|
|
3191
|
+
}
|
|
2601
3192
|
: { name: entry.name }
|
|
2602
3193
|
),
|
|
2603
3194
|
revocationFiles: revocationFiles.map(file => file.name)
|
|
@@ -2615,6 +3206,25 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2615
3206
|
if (entry.kind === 'collection') {
|
|
2616
3207
|
pack.entry({ name: `${entryTarget}/`, type: 'directory', mtime })
|
|
2617
3208
|
for (const file of entry.files) {
|
|
3209
|
+
if ('chunkDir' in file) {
|
|
3210
|
+
// A chunked Resource's `.chunks.<encId>/` subdirectory: emit the
|
|
3211
|
+
// directory entry, then each file -- an inline `.meta.<index>.json`
|
|
3212
|
+
// sidecar, or a chunk representation whose bytes are fetched one at
|
|
3213
|
+
// a time.
|
|
3214
|
+
const dirTarget = `${entryTarget}/${file.name}`
|
|
3215
|
+
pack.entry({ name: `${dirTarget}/`, type: 'directory', mtime })
|
|
3216
|
+
for (const chunkFile of file.chunkDir) {
|
|
3217
|
+
const bytes =
|
|
3218
|
+
'bytes' in chunkFile
|
|
3219
|
+
? chunkFile.bytes
|
|
3220
|
+
: await this._chunkContent({ spaceId, ...chunkFile.chunk })
|
|
3221
|
+
pack.entry(
|
|
3222
|
+
{ name: `${dirTarget}/${chunkFile.name}`, mtime },
|
|
3223
|
+
bytes
|
|
3224
|
+
)
|
|
3225
|
+
}
|
|
3226
|
+
continue
|
|
3227
|
+
}
|
|
2618
3228
|
const bytes =
|
|
2619
3229
|
'bytes' in file
|
|
2620
3230
|
? file.bytes
|
|
@@ -2663,6 +3273,38 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2663
3273
|
return rows[0]?.content ?? Buffer.alloc(0)
|
|
2664
3274
|
}
|
|
2665
3275
|
|
|
3276
|
+
/**
|
|
3277
|
+
* Fetches one chunk's bytes for the export pack loop (the `chunked-streams`
|
|
3278
|
+
* feature). A chunk removed between the metadata pass and this read yields an
|
|
3279
|
+
* empty body rather than failing the whole archive, matching
|
|
3280
|
+
* `_resourceContent`.
|
|
3281
|
+
* @param options {object}
|
|
3282
|
+
* @param options.spaceId {string}
|
|
3283
|
+
* @param options.collectionId {string}
|
|
3284
|
+
* @param options.resourceId {string}
|
|
3285
|
+
* @param options.chunkIndex {number}
|
|
3286
|
+
* @returns {Promise<Buffer>}
|
|
3287
|
+
*/
|
|
3288
|
+
private async _chunkContent({
|
|
3289
|
+
spaceId,
|
|
3290
|
+
collectionId,
|
|
3291
|
+
resourceId,
|
|
3292
|
+
chunkIndex
|
|
3293
|
+
}: {
|
|
3294
|
+
spaceId: string
|
|
3295
|
+
collectionId: string
|
|
3296
|
+
resourceId: string
|
|
3297
|
+
chunkIndex: number
|
|
3298
|
+
}): Promise<Buffer> {
|
|
3299
|
+
const { rows } = await this._pool.query<{ bytes: Buffer | null }>(
|
|
3300
|
+
`SELECT bytes FROM chunks
|
|
3301
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
3302
|
+
AND chunk_index = $4`,
|
|
3303
|
+
[spaceId, collectionId, resourceId, chunkIndex]
|
|
3304
|
+
)
|
|
3305
|
+
return rows[0]?.bytes ?? Buffer.alloc(0)
|
|
3306
|
+
}
|
|
3307
|
+
|
|
2666
3308
|
/**
|
|
2667
3309
|
* Merges a WAS space-export tarball into an existing Space with the same
|
|
2668
3310
|
* three-invariant pre-flight (per-entry 413, fail-closed 422 encryption
|
|
@@ -2685,6 +3327,10 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2685
3327
|
}): Promise<ImportStats> {
|
|
2686
3328
|
const entries = await extractTarEntries(tarStream)
|
|
2687
3329
|
const { spacePolicy, collections, revocations } = buildImportPlan(entries)
|
|
3330
|
+
// Chunk entries (the `chunked-streams` feature) live in per-Resource
|
|
3331
|
+
// `.chunks.<encId>/` subdirectories, which `buildImportPlan` skips (it
|
|
3332
|
+
// ignores nested files); parse them straight off the raw tar entries.
|
|
3333
|
+
const chunkEntries = this._chunkEntriesFromArchive(entries)
|
|
2688
3334
|
const {
|
|
2689
3335
|
capacityBytes,
|
|
2690
3336
|
maxUploadBytes,
|
|
@@ -2774,6 +3420,19 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2774
3420
|
incomingBytes += body.length
|
|
2775
3421
|
}
|
|
2776
3422
|
}
|
|
3423
|
+
// Chunks (the `chunked-streams` feature) are opaque bytes -- no
|
|
3424
|
+
// encryption-conformance check applies -- but they count the same per-body
|
|
3425
|
+
// 413 and the (conservative) capacity pre-flight as Resource bodies.
|
|
3426
|
+
for (const chunk of chunkEntries) {
|
|
3427
|
+
if (chunk.body.length > maxUploadBytes) {
|
|
3428
|
+
throw new PayloadTooLargeError({
|
|
3429
|
+
maxUploadBytes,
|
|
3430
|
+
backendId: this.describe().id,
|
|
3431
|
+
uploadBytes: chunk.body.length
|
|
3432
|
+
})
|
|
3433
|
+
}
|
|
3434
|
+
incomingBytes += chunk.body.length
|
|
3435
|
+
}
|
|
2777
3436
|
if (
|
|
2778
3437
|
capacityBytes !== undefined &&
|
|
2779
3438
|
currentUsage + incomingBytes > capacityBytes
|
|
@@ -2966,13 +3625,63 @@ export class PostgresBackend implements StorageBackend {
|
|
|
2966
3625
|
}
|
|
2967
3626
|
}
|
|
2968
3627
|
|
|
2969
|
-
|
|
3628
|
+
// Chunks (the `chunked-streams` feature): restore each archived chunk
|
|
3629
|
+
// skip-not-overwrite, after the Resource apply loop so a chunk's parent
|
|
3630
|
+
// Resource row already exists in this transaction (the foreign key
|
|
3631
|
+
// requires it). An orphan chunk -- one whose parent is absent or a
|
|
3632
|
+
// tombstone -- is skipped rather than resurrected. Existing chunk rows are
|
|
3633
|
+
// left untouched.
|
|
3634
|
+
let createdChunkBytes = 0
|
|
3635
|
+
for (const chunk of chunkEntries) {
|
|
3636
|
+
const { rows: parentRows } = await client.query<{ deleted: boolean }>(
|
|
3637
|
+
`SELECT deleted FROM resources
|
|
3638
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3`,
|
|
3639
|
+
[spaceId, chunk.collectionId, chunk.resourceId]
|
|
3640
|
+
)
|
|
3641
|
+
const parent = parentRows[0]
|
|
3642
|
+
if (!parent || parent.deleted) {
|
|
3643
|
+
continue
|
|
3644
|
+
}
|
|
3645
|
+
const { rows: existingChunkRows } = await client.query(
|
|
3646
|
+
`SELECT 1 FROM chunks
|
|
3647
|
+
WHERE space_id = $1 AND collection_id = $2 AND resource_id = $3
|
|
3648
|
+
AND chunk_index = $4`,
|
|
3649
|
+
[spaceId, chunk.collectionId, chunk.resourceId, chunk.chunkIndex]
|
|
3650
|
+
)
|
|
3651
|
+
if (existingChunkRows.length > 0) {
|
|
3652
|
+
continue
|
|
3653
|
+
}
|
|
3654
|
+
// The chunk's `version` comes from its archived `.meta.<index>.json`
|
|
3655
|
+
// sidecar; an archive without one (or a chunk written before sidecars)
|
|
3656
|
+
// starts at version 1, the same fresh-write default as a Resource
|
|
3657
|
+
// restored without a sidecar.
|
|
3658
|
+
await client.query(
|
|
3659
|
+
`INSERT INTO chunks (
|
|
3660
|
+
space_id, collection_id, resource_id, chunk_index,
|
|
3661
|
+
content_type, bytes, size, version
|
|
3662
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
|
3663
|
+
[
|
|
3664
|
+
spaceId,
|
|
3665
|
+
chunk.collectionId,
|
|
3666
|
+
chunk.resourceId,
|
|
3667
|
+
chunk.chunkIndex,
|
|
3668
|
+
chunk.contentType,
|
|
3669
|
+
chunk.body,
|
|
3670
|
+
chunk.body.length,
|
|
3671
|
+
chunk.version ?? 1
|
|
3672
|
+
]
|
|
3673
|
+
)
|
|
3674
|
+
createdChunkBytes += chunk.body.length
|
|
3675
|
+
}
|
|
3676
|
+
|
|
3677
|
+
const createdTotalBytes = createdBytes + createdChunkBytes
|
|
3678
|
+
if (createdTotalBytes > 0) {
|
|
2970
3679
|
// The pre-flight was conservative (it counted skips too), so the
|
|
2971
3680
|
// actual created total always fits; apply it unguarded.
|
|
2972
3681
|
await this._applyUsageDelta({
|
|
2973
3682
|
client,
|
|
2974
3683
|
spaceId,
|
|
2975
|
-
delta:
|
|
3684
|
+
delta: createdTotalBytes
|
|
2976
3685
|
})
|
|
2977
3686
|
}
|
|
2978
3687
|
|
|
@@ -3070,4 +3779,141 @@ export class PostgresBackend implements StorageBackend {
|
|
|
3070
3779
|
]
|
|
3071
3780
|
)
|
|
3072
3781
|
}
|
|
3782
|
+
|
|
3783
|
+
/**
|
|
3784
|
+
* Parses a Space-export archive's chunk entries (the `chunked-streams`
|
|
3785
|
+
* feature) out of the raw tar entry map, in the filesystem backend's on-disk
|
|
3786
|
+
* layout so archives round-trip between the two backends. Chunk files live at
|
|
3787
|
+
* `space/<sourceSpaceId>/<collectionId>/.chunks.<encResourceId>/<file>` --
|
|
3788
|
+
* where a chunk is a Resource keyed by its stringified index: an
|
|
3789
|
+
* `r.<index>.<encContentType>.<ext>` representation paired with an optional
|
|
3790
|
+
* `.meta.<index>.json` version sidecar. `buildImportPlan` skips these (it
|
|
3791
|
+
* ignores nested Collection-dir files), so they are parsed here. Ids parsed
|
|
3792
|
+
* from the archive are validated (the path-traversal guard); a file whose
|
|
3793
|
+
* raw index segment is not canonical (`parseChunkIndexSegment`) is ignored,
|
|
3794
|
+
* and a sidecar with no paired representation is dropped (a chunk keeps no
|
|
3795
|
+
* tombstone).
|
|
3796
|
+
* @param entries {Map<string, TarEntry>}
|
|
3797
|
+
* @returns {Array<{ collectionId: string, resourceId: string, chunkIndex:
|
|
3798
|
+
* number, contentType: string, body: Buffer, version?: number }>}
|
|
3799
|
+
*/
|
|
3800
|
+
private _chunkEntriesFromArchive(entries: Map<string, TarEntry>): Array<{
|
|
3801
|
+
collectionId: string
|
|
3802
|
+
resourceId: string
|
|
3803
|
+
chunkIndex: number
|
|
3804
|
+
contentType: string
|
|
3805
|
+
body: Buffer
|
|
3806
|
+
version?: number
|
|
3807
|
+
}> {
|
|
3808
|
+
// Accumulate the representation and the sidecar of each chunk under one
|
|
3809
|
+
// key, then emit only the chunks that carry a representation.
|
|
3810
|
+
const staged = new Map<
|
|
3811
|
+
string,
|
|
3812
|
+
{
|
|
3813
|
+
collectionId: string
|
|
3814
|
+
resourceId: string
|
|
3815
|
+
chunkIndex: number
|
|
3816
|
+
contentType?: string
|
|
3817
|
+
body?: Buffer
|
|
3818
|
+
version?: number
|
|
3819
|
+
}
|
|
3820
|
+
>()
|
|
3821
|
+
const pattern = /^space\/[^/]+\/([^/]+)\/(\.chunks\.[^/]+)\/([^/]+)$/
|
|
3822
|
+
for (const [entryName, entry] of entries) {
|
|
3823
|
+
if (entry.type !== 'file' || !entry.body) {
|
|
3824
|
+
continue
|
|
3825
|
+
}
|
|
3826
|
+
const match = entryName.match(pattern)
|
|
3827
|
+
if (!match) {
|
|
3828
|
+
continue
|
|
3829
|
+
}
|
|
3830
|
+
const collectionId = match[1]!
|
|
3831
|
+
const resourceId = parseChunkDirName(match[2]!)
|
|
3832
|
+
const fileName = match[3]!
|
|
3833
|
+
if (resourceId === undefined) {
|
|
3834
|
+
continue
|
|
3835
|
+
}
|
|
3836
|
+
// Reject a path-traversal / non-URL-safe id parsed from the archive
|
|
3837
|
+
// before it keys a destination row.
|
|
3838
|
+
assertValidId(collectionId, {
|
|
3839
|
+
kind: 'collection',
|
|
3840
|
+
requestName: 'Import Space'
|
|
3841
|
+
})
|
|
3842
|
+
assertValidId(resourceId, {
|
|
3843
|
+
kind: 'resource',
|
|
3844
|
+
requestName: 'Import Space'
|
|
3845
|
+
})
|
|
3846
|
+
|
|
3847
|
+
// A chunk's index is the "resource id" of its file within the chunk dir:
|
|
3848
|
+
// `r.<index>.<encType>.<ext>` for the representation, `.meta.<index>.json`
|
|
3849
|
+
// for the version sidecar. The RAW index segment must pass the same
|
|
3850
|
+
// canonical predicate as the live route (`parseChunkIndexSegment`), so a
|
|
3851
|
+
// non-canonical spelling (`r.01.*`, `r.%31.*`) is dropped rather than
|
|
3852
|
+
// coerced onto -- and colliding with -- the canonical chunk's row.
|
|
3853
|
+
let chunkIdSegment: string | undefined
|
|
3854
|
+
let representation: { contentType: string; body: Buffer } | undefined
|
|
3855
|
+
let sidecarVersion: number | undefined
|
|
3856
|
+
const metaId = metaSidecarFileId(fileName)
|
|
3857
|
+
if (metaId !== undefined) {
|
|
3858
|
+
chunkIdSegment = metaId
|
|
3859
|
+
try {
|
|
3860
|
+
const sidecar = JSON.parse(
|
|
3861
|
+
entry.body.toString('utf8')
|
|
3862
|
+
) as ChunkSidecar
|
|
3863
|
+
sidecarVersion = sidecar.version
|
|
3864
|
+
} catch {
|
|
3865
|
+
sidecarVersion = undefined
|
|
3866
|
+
}
|
|
3867
|
+
} else if (fileName.startsWith('r.')) {
|
|
3868
|
+
const { contentType } = parseResourceFileName(fileName)
|
|
3869
|
+
chunkIdSegment = fileName.split('.')[1]
|
|
3870
|
+
representation = { contentType, body: entry.body }
|
|
3871
|
+
} else {
|
|
3872
|
+
continue
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
const chunkIndex =
|
|
3876
|
+
chunkIdSegment === undefined
|
|
3877
|
+
? undefined
|
|
3878
|
+
: parseChunkIndexSegment(chunkIdSegment)
|
|
3879
|
+
if (chunkIndex === undefined) {
|
|
3880
|
+
continue
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
const key = `${collectionId}/${resourceId}/${chunkIndex}`
|
|
3884
|
+
const slot = staged.get(key) ?? { collectionId, resourceId, chunkIndex }
|
|
3885
|
+
if (representation !== undefined) {
|
|
3886
|
+
slot.contentType = representation.contentType
|
|
3887
|
+
slot.body = representation.body
|
|
3888
|
+
}
|
|
3889
|
+
if (sidecarVersion !== undefined) {
|
|
3890
|
+
slot.version = sidecarVersion
|
|
3891
|
+
}
|
|
3892
|
+
staged.set(key, slot)
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
const parsed: Array<{
|
|
3896
|
+
collectionId: string
|
|
3897
|
+
resourceId: string
|
|
3898
|
+
chunkIndex: number
|
|
3899
|
+
contentType: string
|
|
3900
|
+
body: Buffer
|
|
3901
|
+
version?: number
|
|
3902
|
+
}> = []
|
|
3903
|
+
for (const slot of staged.values()) {
|
|
3904
|
+
if (slot.body === undefined) {
|
|
3905
|
+
// A sidecar with no paired representation is not a valid chunk.
|
|
3906
|
+
continue
|
|
3907
|
+
}
|
|
3908
|
+
parsed.push({
|
|
3909
|
+
collectionId: slot.collectionId,
|
|
3910
|
+
resourceId: slot.resourceId,
|
|
3911
|
+
chunkIndex: slot.chunkIndex,
|
|
3912
|
+
contentType: slot.contentType ?? 'application/octet-stream',
|
|
3913
|
+
body: slot.body,
|
|
3914
|
+
version: slot.version
|
|
3915
|
+
})
|
|
3916
|
+
}
|
|
3917
|
+
return parsed
|
|
3918
|
+
}
|
|
3073
3919
|
}
|