was-teaching-server 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -1
- package/README.md +23 -11
- package/dist/backends/filesystem.d.ts +247 -4
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +711 -81
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +240 -2
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +800 -42
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +69 -57
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +26 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +40 -12
- package/dist/errors.js.map +1 -1
- package/dist/lib/equalityIndex.d.ts +334 -0
- package/dist/lib/equalityIndex.d.ts.map +1 -0
- package/dist/lib/equalityIndex.js +552 -0
- package/dist/lib/equalityIndex.js.map +1 -0
- package/dist/lib/exportManifest.d.ts +9 -0
- package/dist/lib/exportManifest.d.ts.map +1 -1
- package/dist/lib/exportManifest.js +9 -0
- package/dist/lib/exportManifest.js.map +1 -1
- package/dist/lib/importTar.d.ts +18 -2
- package/dist/lib/importTar.d.ts.map +1 -1
- package/dist/lib/importTar.js +77 -4
- package/dist/lib/importTar.js.map +1 -1
- package/dist/lib/paths.d.ts +33 -0
- package/dist/lib/paths.d.ts.map +1 -1
- package/dist/lib/paths.js +28 -0
- package/dist/lib/paths.js.map +1 -1
- package/dist/lib/resourceFileName.d.ts +43 -0
- package/dist/lib/resourceFileName.d.ts.map +1 -1
- package/dist/lib/resourceFileName.js +63 -0
- package/dist/lib/resourceFileName.js.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -2
- package/dist/plugin.js.map +1 -1
- package/dist/requests/ChunkRequest.d.ts +104 -0
- package/dist/requests/ChunkRequest.d.ts.map +1 -0
- package/dist/requests/ChunkRequest.js +396 -0
- package/dist/requests/ChunkRequest.js.map +1 -0
- package/dist/requests/CollectionRequest.d.ts +22 -5
- package/dist/requests/CollectionRequest.d.ts.map +1 -1
- package/dist/requests/CollectionRequest.js +192 -11
- package/dist/requests/CollectionRequest.js.map +1 -1
- package/dist/requests/ResourceRequest.d.ts.map +1 -1
- package/dist/requests/ResourceRequest.js +20 -0
- package/dist/requests/ResourceRequest.js.map +1 -1
- package/dist/requests/SpaceRequest.d.ts +1 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -1
- package/dist/requests/SpaceRequest.js +21 -1
- package/dist/requests/SpaceRequest.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +21 -0
- package/dist/routes.js.map +1 -1
- package/dist/types.d.ts +165 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/backends/filesystem.ts +924 -75
- package/src/backends/postgres.ts +1114 -32
- package/src/backends/postgresSchema.ts +69 -57
- package/src/errors.ts +44 -12
- package/src/lib/equalityIndex.ts +769 -0
- package/src/lib/importTar.ts +107 -4
- package/src/lib/paths.ts +48 -0
- package/src/lib/resourceFileName.ts +69 -0
- package/src/plugin.ts +5 -2
- package/src/requests/ChunkRequest.ts +478 -0
- package/src/requests/CollectionRequest.ts +221 -12
- package/src/requests/ResourceRequest.ts +20 -0
- package/src/requests/SpaceRequest.ts +25 -1
- package/src/routes.ts +46 -0
- package/src/types.ts +175 -1
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
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -212,10 +212,13 @@ async function wasPlugin(
|
|
|
212
212
|
(onboardingToken ? onboardingTokenAuthorizer(onboardingToken) : undefined)
|
|
213
213
|
)
|
|
214
214
|
|
|
215
|
-
// Disable CORS
|
|
215
|
+
// Disable CORS. `exposedHeaders` is required for browser clients: without
|
|
216
|
+
// it, cross-origin JS cannot read `Location` (space/resource creation),
|
|
217
|
+
// `ETag` (metaVersion concurrency), or `Link` (pagination, policy linksets).
|
|
216
218
|
fastify.register(cors, {
|
|
217
219
|
origin: '*',
|
|
218
|
-
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
220
|
+
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
221
|
+
exposedHeaders: ['Location', 'ETag', 'Link']
|
|
219
222
|
})
|
|
220
223
|
|
|
221
224
|
// Multipart file uploading. The cap is `files: 2`, not `1`: a write MUST carry
|