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.
Files changed (52) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/dist/backends/filesystem.d.ts +161 -2
  3. package/dist/backends/filesystem.d.ts.map +1 -1
  4. package/dist/backends/filesystem.js +491 -60
  5. package/dist/backends/filesystem.js.map +1 -1
  6. package/dist/backends/postgres.d.ts +166 -0
  7. package/dist/backends/postgres.d.ts.map +1 -1
  8. package/dist/backends/postgres.js +625 -29
  9. package/dist/backends/postgres.js.map +1 -1
  10. package/dist/backends/postgresSchema.d.ts.map +1 -1
  11. package/dist/backends/postgresSchema.js +25 -0
  12. package/dist/backends/postgresSchema.js.map +1 -1
  13. package/dist/errors.d.ts +13 -0
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +18 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/lib/exportManifest.d.ts +9 -0
  18. package/dist/lib/exportManifest.d.ts.map +1 -1
  19. package/dist/lib/exportManifest.js +9 -0
  20. package/dist/lib/exportManifest.js.map +1 -1
  21. package/dist/lib/importTar.d.ts +18 -2
  22. package/dist/lib/importTar.d.ts.map +1 -1
  23. package/dist/lib/importTar.js +77 -4
  24. package/dist/lib/importTar.js.map +1 -1
  25. package/dist/lib/paths.d.ts +33 -0
  26. package/dist/lib/paths.d.ts.map +1 -1
  27. package/dist/lib/paths.js +28 -0
  28. package/dist/lib/paths.js.map +1 -1
  29. package/dist/lib/resourceFileName.d.ts +43 -0
  30. package/dist/lib/resourceFileName.d.ts.map +1 -1
  31. package/dist/lib/resourceFileName.js +63 -0
  32. package/dist/lib/resourceFileName.js.map +1 -1
  33. package/dist/requests/ChunkRequest.d.ts +104 -0
  34. package/dist/requests/ChunkRequest.d.ts.map +1 -0
  35. package/dist/requests/ChunkRequest.js +396 -0
  36. package/dist/requests/ChunkRequest.js.map +1 -0
  37. package/dist/routes.d.ts.map +1 -1
  38. package/dist/routes.js +21 -0
  39. package/dist/routes.js.map +1 -1
  40. package/dist/types.d.ts +81 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/backends/filesystem.ts +638 -57
  44. package/src/backends/postgres.ts +867 -21
  45. package/src/backends/postgresSchema.ts +69 -57
  46. package/src/errors.ts +19 -0
  47. package/src/lib/importTar.ts +107 -4
  48. package/src/lib/paths.ts +48 -0
  49. package/src/lib/resourceFileName.ts +69 -0
  50. package/src/requests/ChunkRequest.ts +478 -0
  51. package/src/routes.ts +46 -0
  52. 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 text COLLATE "C" NOT NULL
40
- REFERENCES spaces ON DELETE CASCADE,
41
- collection_id text COLLATE "C" NOT NULL,
42
- description jsonb,
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
@@ -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
+ }