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
|
@@ -21,7 +21,7 @@ import type pg from 'pg'
|
|
|
21
21
|
* never edit an applied entry.
|
|
22
22
|
*/
|
|
23
23
|
export const MIGRATIONS: string[] = [
|
|
24
|
-
// v1: the full WAS + WebKMS surface.
|
|
24
|
+
// v1: the full WAS + WebKMS + chunked-storage surface.
|
|
25
25
|
`
|
|
26
26
|
-- The Spaces tree. 'description' is NULL for a placeholder row created by a
|
|
27
27
|
-- write below the Space level (a resource/policy write to a Space whose
|
|
@@ -29,17 +29,29 @@ export const MIGRATIONS: string[] = [
|
|
|
29
29
|
-- without a '.space.' file; getSpaceDescription treats it as absent.
|
|
30
30
|
-- 'usage_bytes' is the transactional quota counter (spec "Quotas"),
|
|
31
31
|
-- maintained in the same transaction as every content write and delete.
|
|
32
|
+
-- 'controller' denormalizes the Space controller (also present as
|
|
33
|
+
-- description->>'controller') onto its own indexed column, keeping the
|
|
34
|
+
-- per-controller COUNT(*) for the Spaces count quota cheap and lockable;
|
|
35
|
+
-- writeSpace maintains it on every insert and update.
|
|
32
36
|
CREATE TABLE spaces (
|
|
33
37
|
space_id text COLLATE "C" PRIMARY KEY,
|
|
34
38
|
description jsonb,
|
|
35
|
-
usage_bytes bigint NOT NULL DEFAULT 0
|
|
39
|
+
usage_bytes bigint NOT NULL DEFAULT 0,
|
|
40
|
+
controller text
|
|
36
41
|
);
|
|
42
|
+
CREATE INDEX spaces_controller_idx ON spaces (controller);
|
|
37
43
|
|
|
44
|
+
-- 'description_version' is the monotonic Collection Description version --
|
|
45
|
+
-- the ETag validator behind conditional (If-Match) Collection Description
|
|
46
|
+
-- writes, so concurrent recipient edits compare-and-swap instead of
|
|
47
|
+
-- clobbering. Kept out of the stored 'description' jsonb (it travels only
|
|
48
|
+
-- as the ETag header); writeCollection bumps it on every write.
|
|
38
49
|
CREATE TABLE collections (
|
|
39
|
-
space_id
|
|
40
|
-
|
|
41
|
-
collection_id
|
|
42
|
-
description
|
|
50
|
+
space_id text COLLATE "C" NOT NULL
|
|
51
|
+
REFERENCES spaces ON DELETE CASCADE,
|
|
52
|
+
collection_id text COLLATE "C" NOT NULL,
|
|
53
|
+
description jsonb,
|
|
54
|
+
description_version integer NOT NULL DEFAULT 1,
|
|
43
55
|
PRIMARY KEY (space_id, collection_id)
|
|
44
56
|
);
|
|
45
57
|
|
|
@@ -49,6 +61,15 @@ export const MIGRATIONS: string[] = [
|
|
|
49
61
|
-- 'content_type' records the last-known type on a tombstone. 'version' /
|
|
50
62
|
-- 'meta_version' are the two ETag validators; 'custom' is the user-writable
|
|
51
63
|
-- metadata (or the opaque encryption envelope on an encrypted Collection).
|
|
64
|
+
-- 'created_by' is the Resource's creator -- the DID of the invoker of its
|
|
65
|
+
-- FIRST content write, set once and preserved verbatim thereafter (the
|
|
66
|
+
-- spec's OPTIONAL 'createdBy'); NULL for a row written by a caller with no
|
|
67
|
+
-- resolved invoker. 'epoch' is the client-declared key epoch the content
|
|
68
|
+
-- was encrypted under (multi-recipient encrypted Collections), stored
|
|
69
|
+
-- opaquely (the server never computes or verifies it); NULL when no epoch
|
|
70
|
+
-- was declared (a plaintext Collection, or an encrypted write that omitted
|
|
71
|
+
-- the stamp). Neither is COLLATE "C": unlike 'created_at' / 'updated_at',
|
|
72
|
+
-- they never participate in an ORDER BY or keyset comparison.
|
|
52
73
|
CREATE TABLE resources (
|
|
53
74
|
space_id text COLLATE "C" NOT NULL,
|
|
54
75
|
collection_id text COLLATE "C" NOT NULL,
|
|
@@ -63,6 +84,8 @@ export const MIGRATIONS: string[] = [
|
|
|
63
84
|
deleted boolean NOT NULL DEFAULT false,
|
|
64
85
|
created_at text COLLATE "C" NOT NULL,
|
|
65
86
|
updated_at text COLLATE "C" NOT NULL,
|
|
87
|
+
created_by text,
|
|
88
|
+
epoch text,
|
|
66
89
|
PRIMARY KEY (space_id, collection_id, resource_id),
|
|
67
90
|
FOREIGN KEY (space_id, collection_id)
|
|
68
91
|
REFERENCES collections ON DELETE CASCADE
|
|
@@ -73,6 +96,30 @@ export const MIGRATIONS: string[] = [
|
|
|
73
96
|
CREATE INDEX resources_changes_idx
|
|
74
97
|
ON resources (space_id, collection_id, updated_at, resource_id);
|
|
75
98
|
|
|
99
|
+
-- Chunk storage for chunked Resources. One row per addressed chunk
|
|
100
|
+
-- (space, collection, resource, index); 'bytes' is the opaque chunk
|
|
101
|
+
-- representation (stored exactly like a binary Resource's content, never
|
|
102
|
+
-- parsed), 'size' its byte length (the quota-counter input), and 'version'
|
|
103
|
+
-- the chunk's own monotonic ETag validator (independent of the parent
|
|
104
|
+
-- Resource's). The foreign key to 'resources' gives chunk rows the same
|
|
105
|
+
-- ON DELETE CASCADE the Space/Collection tree already uses, so a HARD
|
|
106
|
+
-- delete of the parent Resource (or its Collection or Space) removes its
|
|
107
|
+
-- chunks with it; a SOFT delete (the tombstone UPDATE in deleteResource)
|
|
108
|
+
-- removes them explicitly in the same transaction instead.
|
|
109
|
+
CREATE TABLE chunks (
|
|
110
|
+
space_id text COLLATE "C" NOT NULL,
|
|
111
|
+
collection_id text COLLATE "C" NOT NULL,
|
|
112
|
+
resource_id text COLLATE "C" NOT NULL,
|
|
113
|
+
chunk_index integer NOT NULL,
|
|
114
|
+
content_type text NOT NULL,
|
|
115
|
+
bytes bytea NOT NULL,
|
|
116
|
+
size bigint NOT NULL DEFAULT 0,
|
|
117
|
+
version integer NOT NULL,
|
|
118
|
+
PRIMARY KEY (space_id, collection_id, resource_id, chunk_index),
|
|
119
|
+
FOREIGN KEY (space_id, collection_id, resource_id)
|
|
120
|
+
REFERENCES resources ON DELETE CASCADE
|
|
121
|
+
);
|
|
122
|
+
|
|
76
123
|
-- Policies for all three levels in one table; '' sentinel columns keep the
|
|
77
124
|
-- primary key total (Postgres PKs reject NULL). Space policy: ('', '');
|
|
78
125
|
-- collection policy: (cid, ''); resource policy: (cid, rid).
|
|
@@ -94,6 +141,22 @@ export const MIGRATIONS: string[] = [
|
|
|
94
141
|
PRIMARY KEY (space_id, backend_id)
|
|
95
142
|
);
|
|
96
143
|
|
|
144
|
+
-- Space-scoped zcap revocations -- the WAS route families' sibling of the
|
|
145
|
+
-- keystore-scoped 'revocations' table below. A separate table (rather than
|
|
146
|
+
-- a nullable-FK union over one table) lets each keep its own
|
|
147
|
+
-- ON DELETE CASCADE to its parent and its own composite primary key;
|
|
148
|
+
-- deleting a Space here deletes its revocations. Columns mirror
|
|
149
|
+
-- 'revocations', with 'space_id' referencing the spaces tree in place of
|
|
150
|
+
-- 'keystore_id'.
|
|
151
|
+
CREATE TABLE space_revocations (
|
|
152
|
+
space_id text COLLATE "C" NOT NULL REFERENCES spaces ON DELETE CASCADE,
|
|
153
|
+
delegator text COLLATE "C" NOT NULL,
|
|
154
|
+
capability_id text COLLATE "C" NOT NULL,
|
|
155
|
+
record jsonb NOT NULL,
|
|
156
|
+
expires text COLLATE "C",
|
|
157
|
+
PRIMARY KEY (space_id, delegator, capability_id)
|
|
158
|
+
);
|
|
159
|
+
|
|
97
160
|
-- WebKMS facet: a sibling tree to spaces, exactly as on the filesystem.
|
|
98
161
|
-- 'controller' / 'sequence' / 'kms_module' are denormalized from the
|
|
99
162
|
-- verbatim config for the list filter and the update gates.
|
|
@@ -126,57 +189,6 @@ export const MIGRATIONS: string[] = [
|
|
|
126
189
|
expires text COLLATE "C",
|
|
127
190
|
PRIMARY KEY (keystore_id, delegator, capability_id)
|
|
128
191
|
);
|
|
129
|
-
`,
|
|
130
|
-
// v2: denormalize a Space's controller onto its own column for the Spaces
|
|
131
|
-
// count quota (spec "Quotas"). The controller also lives inside 'description'
|
|
132
|
-
// (description->>'controller'), but a dedicated, indexed column keeps the
|
|
133
|
-
// per-controller COUNT(*) cheap and lockable. Backfill from existing rows;
|
|
134
|
-
// writeSpace maintains it on every insert and update.
|
|
135
|
-
`
|
|
136
|
-
ALTER TABLE spaces ADD COLUMN controller text;
|
|
137
|
-
UPDATE spaces SET controller = description->>'controller';
|
|
138
|
-
CREATE INDEX spaces_controller_idx ON spaces (controller);
|
|
139
|
-
`,
|
|
140
|
-
// v3: record a Resource's creator -- the DID of the invoker of its FIRST
|
|
141
|
-
// content write, set once and preserved verbatim thereafter (the spec's
|
|
142
|
-
// OPTIONAL `createdBy`). NULL for a pre-existing row and for one written by
|
|
143
|
-
// a caller with no resolved invoker. Not `COLLATE "C"`: unlike `created_at` /
|
|
144
|
-
// `updated_at`, this column never participates in an `ORDER BY` or keyset
|
|
145
|
-
// comparison.
|
|
146
|
-
`
|
|
147
|
-
ALTER TABLE resources ADD COLUMN created_by text;
|
|
148
|
-
`,
|
|
149
|
-
// v4: Space-scoped zcap revocations -- the WAS route families' sibling of the
|
|
150
|
-
// keystore-scoped `revocations` table above. A separate table (rather than a
|
|
151
|
-
// nullable-FK union over one table) lets each keep its own `ON DELETE CASCADE`
|
|
152
|
-
// to its parent and its own composite primary key; deleting a Space here
|
|
153
|
-
// deletes its revocations. Columns mirror `revocations`, with 'space_id'
|
|
154
|
-
// referencing the spaces tree in place of 'keystore_id'.
|
|
155
|
-
`
|
|
156
|
-
CREATE TABLE space_revocations (
|
|
157
|
-
space_id text COLLATE "C" NOT NULL REFERENCES spaces ON DELETE CASCADE,
|
|
158
|
-
delegator text COLLATE "C" NOT NULL,
|
|
159
|
-
capability_id text COLLATE "C" NOT NULL,
|
|
160
|
-
record jsonb NOT NULL,
|
|
161
|
-
expires text COLLATE "C",
|
|
162
|
-
PRIMARY KEY (space_id, delegator, capability_id)
|
|
163
|
-
);
|
|
164
|
-
`,
|
|
165
|
-
// v5: the client-declared key epoch a Resource's content was encrypted under
|
|
166
|
-
// (multi-recipient encrypted Collections). Stored opaquely: the server never
|
|
167
|
-
// computes or verifies it. NULL when no epoch was declared (a plaintext
|
|
168
|
-
// Collection, or an encrypted write that omitted the stamp). Not `COLLATE "C"`
|
|
169
|
-
// -- it never participates in an `ORDER BY` or keyset comparison.
|
|
170
|
-
`
|
|
171
|
-
ALTER TABLE resources ADD COLUMN epoch text;
|
|
172
|
-
`,
|
|
173
|
-
// v6: the monotonic Collection Description version -- the ETag validator behind
|
|
174
|
-
// conditional (`If-Match`) Collection Description writes, so concurrent
|
|
175
|
-
// recipient edits compare-and-swap instead of clobbering. Kept out of the
|
|
176
|
-
// stored 'description' jsonb (it travels only as the `ETag` header). Backfill
|
|
177
|
-
// existing rows to 1; writeCollection bumps it on every write.
|
|
178
|
-
`
|
|
179
|
-
ALTER TABLE collections ADD COLUMN description_version integer NOT NULL DEFAULT 1;
|
|
180
192
|
`
|
|
181
193
|
]
|
|
182
194
|
|
package/src/errors.ts
CHANGED
|
@@ -124,6 +124,25 @@ export class InvalidResourceIdError extends ProblemError {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 400 — the `:chunkIndex` path param of a chunk operation (the
|
|
129
|
+
* `chunked-streams` feature) is not a canonical non-negative integer. The
|
|
130
|
+
* canonical-form requirement (no leading zeros, no sign) keeps every chunk
|
|
131
|
+
* addressable at exactly one URL.
|
|
132
|
+
* @param options {object}
|
|
133
|
+
* @param [options.requestName] {string} request name used in the error title
|
|
134
|
+
*/
|
|
135
|
+
export class InvalidChunkIndexError extends ProblemError {
|
|
136
|
+
constructor({ requestName }: { requestName?: string } = {}) {
|
|
137
|
+
super({
|
|
138
|
+
type: ProblemTypes.INVALID_ID,
|
|
139
|
+
title: `Invalid ${requestName || 'Chunk'} request`,
|
|
140
|
+
detail: 'Invalid chunk index (must be a canonical non-negative integer).',
|
|
141
|
+
statusCode: 400
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
/**
|
|
128
147
|
* 409 — a `POST` create operation supplied an `id` that already exists.
|
|
129
148
|
* Create-or-replace at a client-chosen id is the idempotent `PUT` path, which
|
package/src/lib/importTar.ts
CHANGED
|
@@ -2,7 +2,11 @@ import * as tar from 'tar-stream'
|
|
|
2
2
|
import YAML from 'yaml'
|
|
3
3
|
import type { Readable } from 'node:stream'
|
|
4
4
|
import { assertValidId } from './validateId.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
parseChunkDirName,
|
|
7
|
+
parseChunkIndexSegment,
|
|
8
|
+
parseResourceFileName
|
|
9
|
+
} from './resourceFileName.js'
|
|
6
10
|
import { InvalidImportError } from '../errors.js'
|
|
7
11
|
import type {
|
|
8
12
|
CollectionDescription,
|
|
@@ -53,6 +57,60 @@ export function metaSidecarFileId(fileName: string): string | undefined {
|
|
|
53
57
|
return dotFileId(fileName, META_PREFIX)
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* If `fileName` is a chunk entry relative to its Collection dir
|
|
62
|
+
* (`.chunks.<encodedResourceId>/<chunkFile>`, the `chunked-streams` feature),
|
|
63
|
+
* returns the parent `resourceId` (decoded from the directory name) and the
|
|
64
|
+
* `chunkFileName` (its single-segment basename inside the chunk directory);
|
|
65
|
+
* otherwise undefined. Any deeper nesting or a traversal segment in the chunk
|
|
66
|
+
* file name is rejected (undefined), so the caller never builds a path from it.
|
|
67
|
+
* @param fileName {string} the entry name relative to the Collection dir
|
|
68
|
+
* @returns {{ resourceId: string, chunkFileName: string } | undefined}
|
|
69
|
+
*/
|
|
70
|
+
function chunkEntryName(
|
|
71
|
+
fileName: string
|
|
72
|
+
): { resourceId: string; chunkFileName: string } | undefined {
|
|
73
|
+
const slash = fileName.indexOf('/')
|
|
74
|
+
if (slash === -1) {
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
const resourceId = parseChunkDirName(fileName.slice(0, slash))
|
|
78
|
+
if (resourceId === undefined) {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
const chunkFileName = fileName.slice(slash + 1)
|
|
82
|
+
if (
|
|
83
|
+
chunkFileName.length === 0 ||
|
|
84
|
+
chunkFileName.includes('/') ||
|
|
85
|
+
chunkFileName === '..'
|
|
86
|
+
) {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
return { resourceId, chunkFileName }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The canonical-chunk-index gate for an archive chunk file: returns true only
|
|
94
|
+
* when `chunkFileName` is a chunk representation (`r.<index>.<encType>.<ext>`)
|
|
95
|
+
* or its version sidecar (`.meta.<index>.json`) whose RAW `<index>` segment
|
|
96
|
+
* passes {@link parseChunkIndexSegment} -- the same predicate the live route
|
|
97
|
+
* enforces. Validating the raw (undecoded) segment rejects both non-canonical
|
|
98
|
+
* spellings (`r.01.*`, which would alias chunk 1) and percent-encoded ones
|
|
99
|
+
* (`r.%31.*`), so an imported chunk file is always reachable at exactly the
|
|
100
|
+
* URL the listing advertises. Anything else in a chunk directory is dropped.
|
|
101
|
+
* @param chunkFileName {string} the file's basename inside the chunk dir
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function isCanonicalChunkFileName(chunkFileName: string): boolean {
|
|
105
|
+
const indexSegment =
|
|
106
|
+
metaSidecarFileId(chunkFileName) ??
|
|
107
|
+
(chunkFileName.startsWith('r.') ? chunkFileName.split('.')[1] : undefined)
|
|
108
|
+
return (
|
|
109
|
+
indexSegment !== undefined &&
|
|
110
|
+
parseChunkIndexSegment(indexSegment) !== undefined
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
56
114
|
/** One extracted archive entry, keyed by its archive path. */
|
|
57
115
|
export interface TarEntry {
|
|
58
116
|
type: 'file' | 'directory'
|
|
@@ -66,6 +124,19 @@ export interface ImportPlanResource {
|
|
|
66
124
|
body: Buffer
|
|
67
125
|
}
|
|
68
126
|
|
|
127
|
+
/**
|
|
128
|
+
* One chunk file of a chunked Resource staged for import (the `chunked-streams`
|
|
129
|
+
* feature): the raw bytes carried verbatim, keyed by the parent `resourceId`
|
|
130
|
+
* (decoded from the `.chunks.<encId>/` directory name) and the chunk file's
|
|
131
|
+
* basename inside that directory (`r.<index>...` bytes or its `.meta.<index>.json`
|
|
132
|
+
* version sidecar).
|
|
133
|
+
*/
|
|
134
|
+
export interface ImportPlanChunkFile {
|
|
135
|
+
resourceId: string
|
|
136
|
+
fileName: string
|
|
137
|
+
body: Buffer
|
|
138
|
+
}
|
|
139
|
+
|
|
69
140
|
/** One collection (plus its resources and policies) staged for import. */
|
|
70
141
|
export interface ImportPlanCollection {
|
|
71
142
|
collectionId: string
|
|
@@ -77,6 +148,8 @@ export interface ImportPlanCollection {
|
|
|
77
148
|
resourcePolicies: Map<string, PolicyDocument>
|
|
78
149
|
/** Resource metadata sidecars (raw `.meta.<id>.json` bytes), keyed by resourceId. */
|
|
79
150
|
resourceMetadata: Map<string, Buffer>
|
|
151
|
+
/** Chunk files of chunked Resources in this Collection, carried verbatim. */
|
|
152
|
+
chunkFiles: ImportPlanChunkFile[]
|
|
80
153
|
}
|
|
81
154
|
|
|
82
155
|
/** The merge plan produced by {@link buildImportPlan}. */
|
|
@@ -185,10 +258,12 @@ export function validateManifest(entries: Map<string, TarEntry>): void {
|
|
|
185
258
|
* - space/<sourceSpaceId>/<collectionId>/.policy.<collectionId>.json (collection policy)
|
|
186
259
|
* - space/<sourceSpaceId>/<collectionId>/.policy.<resourceId>.json (resource policy)
|
|
187
260
|
* - space/<sourceSpaceId>/<collectionId>/r.<resourceId>.<encodedContentType>.<ext>
|
|
261
|
+
* - space/<sourceSpaceId>/<collectionId>/.chunks.<encodedResourceId>/<chunkFile>
|
|
262
|
+
* (a chunked Resource's chunk files; the `chunked-streams` feature)
|
|
188
263
|
*
|
|
189
264
|
* The source space id in the path may differ from the import target; collection
|
|
190
|
-
* metadata, r.* resource files,
|
|
191
|
-
* destination.
|
|
265
|
+
* metadata, r.* resource files, `.chunks.*` chunk files, and `.policy.*` policy
|
|
266
|
+
* files are merged into the destination.
|
|
192
267
|
*
|
|
193
268
|
* @param entries {Map<string, TarEntry>}
|
|
194
269
|
* @returns {ImportPlan}
|
|
@@ -246,6 +321,7 @@ export function buildImportPlan(entries: Map<string, TarEntry>): ImportPlan {
|
|
|
246
321
|
let collectionPolicy: PolicyDocument | undefined
|
|
247
322
|
const resourcePolicies = new Map<string, PolicyDocument>()
|
|
248
323
|
const resourceMetadata = new Map<string, Buffer>()
|
|
324
|
+
const chunkFiles: ImportPlanChunkFile[] = []
|
|
249
325
|
for (const [entryName, entry] of entries) {
|
|
250
326
|
if (
|
|
251
327
|
!entryName.startsWith(collectionPrefix) ||
|
|
@@ -256,6 +332,32 @@ export function buildImportPlan(entries: Map<string, TarEntry>): ImportPlan {
|
|
|
256
332
|
}
|
|
257
333
|
|
|
258
334
|
const fileName = entryName.slice(collectionPrefix.length)
|
|
335
|
+
|
|
336
|
+
// Chunk files of a chunked Resource live in a per-Resource subdirectory
|
|
337
|
+
// (`.chunks.<encId>/<chunkFile>`), so they are the one Collection entry
|
|
338
|
+
// carrying a slash. Carry them verbatim, keyed by the parent resourceId
|
|
339
|
+
// decoded from the directory name (validated against path traversal).
|
|
340
|
+
const chunk = chunkEntryName(fileName)
|
|
341
|
+
if (chunk) {
|
|
342
|
+
assertValidId(chunk.resourceId, {
|
|
343
|
+
kind: 'resource',
|
|
344
|
+
requestName: 'Import Space'
|
|
345
|
+
})
|
|
346
|
+
// Drop a chunk file with a non-canonical index (or a stray file that
|
|
347
|
+
// is neither a representation nor a sidecar): written verbatim it
|
|
348
|
+
// would be unreachable at the canonical member URL yet advertised by
|
|
349
|
+
// the listing.
|
|
350
|
+
if (!isCanonicalChunkFileName(chunk.chunkFileName)) {
|
|
351
|
+
continue
|
|
352
|
+
}
|
|
353
|
+
chunkFiles.push({
|
|
354
|
+
resourceId: chunk.resourceId,
|
|
355
|
+
fileName: chunk.chunkFileName,
|
|
356
|
+
body: entry.body
|
|
357
|
+
})
|
|
358
|
+
continue
|
|
359
|
+
}
|
|
360
|
+
|
|
259
361
|
if (fileName.includes('/')) {
|
|
260
362
|
continue
|
|
261
363
|
}
|
|
@@ -325,7 +427,8 @@ export function buildImportPlan(entries: Map<string, TarEntry>): ImportPlan {
|
|
|
325
427
|
collectionPolicy,
|
|
326
428
|
resources,
|
|
327
429
|
resourcePolicies,
|
|
328
|
-
resourceMetadata
|
|
430
|
+
resourceMetadata,
|
|
431
|
+
chunkFiles
|
|
329
432
|
}
|
|
330
433
|
})
|
|
331
434
|
|
package/src/lib/paths.ts
CHANGED
|
@@ -209,6 +209,54 @@ export function metaPath({
|
|
|
209
209
|
return `${resourcePath({ spaceId, collectionId, resourceId })}/meta`
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* `/space/:spaceId/:collectionId/:resourceId/chunks/:chunkIndex` -- a single
|
|
214
|
+
* chunk of a chunked Resource (the `chunked-streams` feature). Chunks are
|
|
215
|
+
* addressed under their parent Resource; like `meta`, the `chunks` segment
|
|
216
|
+
* needs no reserved-id entry (it sits a level below any Resource route).
|
|
217
|
+
* @param options {object}
|
|
218
|
+
* @param options.spaceId {string}
|
|
219
|
+
* @param options.collectionId {string}
|
|
220
|
+
* @param options.resourceId {string}
|
|
221
|
+
* @param options.chunkIndex {number} non-negative integer chunk position
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
export function chunkPath({
|
|
225
|
+
spaceId,
|
|
226
|
+
collectionId,
|
|
227
|
+
resourceId,
|
|
228
|
+
chunkIndex
|
|
229
|
+
}: {
|
|
230
|
+
spaceId: string
|
|
231
|
+
collectionId: string
|
|
232
|
+
resourceId: string
|
|
233
|
+
chunkIndex: number
|
|
234
|
+
}): string {
|
|
235
|
+
return `${resourcePath({ spaceId, collectionId, resourceId })}/chunks/${chunkIndex}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `/space/:spaceId/:collectionId/:resourceId/chunks/` -- the chunk listing
|
|
240
|
+
* (container) path of a chunked Resource. Always the trailing-slash container
|
|
241
|
+
* form: a reader discovers the chunk count here before fetching `0..count-1`.
|
|
242
|
+
* @param options {object}
|
|
243
|
+
* @param options.spaceId {string}
|
|
244
|
+
* @param options.collectionId {string}
|
|
245
|
+
* @param options.resourceId {string}
|
|
246
|
+
* @returns {string}
|
|
247
|
+
*/
|
|
248
|
+
export function chunksContainerPath({
|
|
249
|
+
spaceId,
|
|
250
|
+
collectionId,
|
|
251
|
+
resourceId
|
|
252
|
+
}: {
|
|
253
|
+
spaceId: string
|
|
254
|
+
collectionId: string
|
|
255
|
+
resourceId: string
|
|
256
|
+
}): string {
|
|
257
|
+
return `${resourcePath({ spaceId, collectionId, resourceId })}/chunks/`
|
|
258
|
+
}
|
|
259
|
+
|
|
212
260
|
/**
|
|
213
261
|
* `/space/:spaceId/:collectionId/backend` -- the "Collection Backend Selected"
|
|
214
262
|
* resource path (reserved `backend` segment at the Resource level).
|
|
@@ -67,3 +67,72 @@ export function parseResourceFileName(fileName: string): {
|
|
|
67
67
|
: 'application/octet-stream'
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Prefix of a Resource's per-Resource chunk directory
|
|
73
|
+
* (`.chunks.<encodedResourceId>/`), which holds the chunk representations of a
|
|
74
|
+
* chunked Resource (the `chunked-streams` feature). The leading `.` keeps the
|
|
75
|
+
* directory out of the `r.`-prefixed Collection listing, and dot-escaping the
|
|
76
|
+
* id segment keeps it in one filesystem-name namespace with the Resource files.
|
|
77
|
+
*/
|
|
78
|
+
export const CHUNK_DIR_PREFIX = '.chunks.'
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Builds the on-disk directory name for a Resource's chunk directory:
|
|
82
|
+
* `.chunks.<encodedResourceId>`. The id segment is dot-escaped (see
|
|
83
|
+
* {@link encodeFilenameSegment}) so a dotted id round-trips.
|
|
84
|
+
* @param resourceId {string}
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function chunkDirName(resourceId: string): string {
|
|
88
|
+
return `${CHUNK_DIR_PREFIX}${encodeFilenameSegment(resourceId)}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Largest addressable chunk index. 2^31-1 (`int4` max): the Postgres backend
|
|
93
|
+
* stores the index in an `integer` column, so the shared validation caps it
|
|
94
|
+
* there and both backends agree on the addressable range.
|
|
95
|
+
*/
|
|
96
|
+
export const MAX_CHUNK_INDEX = 2 ** 31 - 1
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Canonical non-negative decimal integer: `0`, or a digit run with no leading
|
|
100
|
+
* zero. Rejecting non-canonical spellings (`01`, `+1`, `1e3`) keeps every chunk
|
|
101
|
+
* addressable at exactly one URL (and at exactly one archive file name).
|
|
102
|
+
*/
|
|
103
|
+
const CHUNK_INDEX_PATTERN = /^(0|[1-9][0-9]*)$/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parses a chunk-index segment -- the `:chunkIndex` path param, or the
|
|
107
|
+
* `<index>` segment of a chunk file name (`r.<index>.<encType>.<ext>` /
|
|
108
|
+
* `.meta.<index>.json`) -- into its number. Returns `undefined` unless the
|
|
109
|
+
* segment is the canonical decimal spelling of an integer in
|
|
110
|
+
* `[0, MAX_CHUNK_INDEX]`. The single shared predicate for the live route and
|
|
111
|
+
* both backends' import paths, so a chunk index means the same thing
|
|
112
|
+
* everywhere.
|
|
113
|
+
* @param segment {string}
|
|
114
|
+
* @returns {number | undefined}
|
|
115
|
+
*/
|
|
116
|
+
export function parseChunkIndexSegment(segment: string): number | undefined {
|
|
117
|
+
if (!CHUNK_INDEX_PATTERN.test(segment)) {
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
const chunkIndex = Number(segment)
|
|
121
|
+
return chunkIndex <= MAX_CHUNK_INDEX ? chunkIndex : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parses a chunk directory name (`.chunks.<encodedResourceId>`) back into its
|
|
126
|
+
* parent `resourceId`, reversing {@link chunkDirName}. Returns `undefined` when
|
|
127
|
+
* the name is not a chunk directory (no matching prefix, or an empty id
|
|
128
|
+
* segment).
|
|
129
|
+
* @param dirName {string} the basename of the directory
|
|
130
|
+
* @returns {string | undefined}
|
|
131
|
+
*/
|
|
132
|
+
export function parseChunkDirName(dirName: string): string | undefined {
|
|
133
|
+
if (!dirName.startsWith(CHUNK_DIR_PREFIX)) {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
const encodedId = dirName.slice(CHUNK_DIR_PREFIX.length)
|
|
137
|
+
return encodedId.length > 0 ? decodeURIComponent(encodedId) : undefined
|
|
138
|
+
}
|