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.
Files changed (76) hide show
  1. package/CHANGELOG.md +118 -1
  2. package/README.md +23 -11
  3. package/dist/backends/filesystem.d.ts +247 -4
  4. package/dist/backends/filesystem.d.ts.map +1 -1
  5. package/dist/backends/filesystem.js +711 -81
  6. package/dist/backends/filesystem.js.map +1 -1
  7. package/dist/backends/postgres.d.ts +240 -2
  8. package/dist/backends/postgres.d.ts.map +1 -1
  9. package/dist/backends/postgres.js +800 -42
  10. package/dist/backends/postgres.js.map +1 -1
  11. package/dist/backends/postgresSchema.d.ts.map +1 -1
  12. package/dist/backends/postgresSchema.js +69 -57
  13. package/dist/backends/postgresSchema.js.map +1 -1
  14. package/dist/errors.d.ts +26 -9
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +40 -12
  17. package/dist/errors.js.map +1 -1
  18. package/dist/lib/equalityIndex.d.ts +334 -0
  19. package/dist/lib/equalityIndex.d.ts.map +1 -0
  20. package/dist/lib/equalityIndex.js +552 -0
  21. package/dist/lib/equalityIndex.js.map +1 -0
  22. package/dist/lib/exportManifest.d.ts +9 -0
  23. package/dist/lib/exportManifest.d.ts.map +1 -1
  24. package/dist/lib/exportManifest.js +9 -0
  25. package/dist/lib/exportManifest.js.map +1 -1
  26. package/dist/lib/importTar.d.ts +18 -2
  27. package/dist/lib/importTar.d.ts.map +1 -1
  28. package/dist/lib/importTar.js +77 -4
  29. package/dist/lib/importTar.js.map +1 -1
  30. package/dist/lib/paths.d.ts +33 -0
  31. package/dist/lib/paths.d.ts.map +1 -1
  32. package/dist/lib/paths.js +28 -0
  33. package/dist/lib/paths.js.map +1 -1
  34. package/dist/lib/resourceFileName.d.ts +43 -0
  35. package/dist/lib/resourceFileName.d.ts.map +1 -1
  36. package/dist/lib/resourceFileName.js +63 -0
  37. package/dist/lib/resourceFileName.js.map +1 -1
  38. package/dist/plugin.d.ts.map +1 -1
  39. package/dist/plugin.js +5 -2
  40. package/dist/plugin.js.map +1 -1
  41. package/dist/requests/ChunkRequest.d.ts +104 -0
  42. package/dist/requests/ChunkRequest.d.ts.map +1 -0
  43. package/dist/requests/ChunkRequest.js +396 -0
  44. package/dist/requests/ChunkRequest.js.map +1 -0
  45. package/dist/requests/CollectionRequest.d.ts +22 -5
  46. package/dist/requests/CollectionRequest.d.ts.map +1 -1
  47. package/dist/requests/CollectionRequest.js +192 -11
  48. package/dist/requests/CollectionRequest.js.map +1 -1
  49. package/dist/requests/ResourceRequest.d.ts.map +1 -1
  50. package/dist/requests/ResourceRequest.js +20 -0
  51. package/dist/requests/ResourceRequest.js.map +1 -1
  52. package/dist/requests/SpaceRequest.d.ts +1 -0
  53. package/dist/requests/SpaceRequest.d.ts.map +1 -1
  54. package/dist/requests/SpaceRequest.js +21 -1
  55. package/dist/requests/SpaceRequest.js.map +1 -1
  56. package/dist/routes.d.ts.map +1 -1
  57. package/dist/routes.js +21 -0
  58. package/dist/routes.js.map +1 -1
  59. package/dist/types.d.ts +165 -2
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +19 -18
  62. package/src/backends/filesystem.ts +924 -75
  63. package/src/backends/postgres.ts +1114 -32
  64. package/src/backends/postgresSchema.ts +69 -57
  65. package/src/errors.ts +44 -12
  66. package/src/lib/equalityIndex.ts +769 -0
  67. package/src/lib/importTar.ts +107 -4
  68. package/src/lib/paths.ts +48 -0
  69. package/src/lib/resourceFileName.ts +69 -0
  70. package/src/plugin.ts +5 -2
  71. package/src/requests/ChunkRequest.ts +478 -0
  72. package/src/requests/CollectionRequest.ts +221 -12
  73. package/src/requests/ResourceRequest.ts +20 -0
  74. package/src/requests/SpaceRequest.ts +25 -1
  75. package/src/routes.ts +46 -0
  76. package/src/types.ts +175 -1
@@ -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 { parseResourceFileName } from './resourceFileName.js'
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, and `.policy.*` policy files are merged into the
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