@telepath-computer/autofile 0.4.0 → 0.5.1

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/dist/store.js CHANGED
@@ -12,20 +12,37 @@ import { setTimeout as delay } from "node:timers/promises";
12
12
  import { lookup as lookupMimeType } from "mime-types";
13
13
  import { Query } from "mingo";
14
14
  import lockfile from "proper-lockfile";
15
- import { MAX_BLOB_BYTES, blobNameFromUri, deleteBlobUris, blobReferenceProblem, blobSizeProblem, isMediaType, writeBlob, } from "./blobs.js";
15
+ import { blobNameFromUri, deleteBlobUris, blobReferenceProblem, isMediaType, writeBlob, writeBlobStream, } from "./blobs.js";
16
16
  import { getWriteLockPath } from "./config.js";
17
17
  import { jsonObjectProblem } from "./json.js";
18
18
  import { prepareSchema, SchemaRefusal } from "./schema.js";
19
19
  import { watchFolder } from "./watch.js";
20
20
  const typeNamePattern = /^[a-z][a-z0-9-]*$/u;
21
21
  const keyPattern = /^[a-z0-9][a-z0-9-]*$/u;
22
- const recordUriPattern = /^autofile:\/\/\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/u;
22
+ const recordUriPattern = /^autofile:\/\/\/records\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/u;
23
+ const collectionUriPattern = /^autofile:\/\/\/records\/collection\/[a-z0-9][a-z0-9-]*$/u;
23
24
  const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
24
25
  const MAX_FIND_RECORDS_PAGE_BYTES = 64 * 1024;
25
26
  const tidAlphabet = "234567abcdefghijklmnopqrstuvwxyz";
26
27
  const invalidTypeMessage = "type: must use lowercase letters, digits, and hyphens and start with a letter";
27
28
  const invalidKeyMessage = "key: must use lowercase letters, digits, and hyphens and start with a letter or digit";
28
- const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///task/book-the-dentist";
29
+ const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///records/task/book-the-dentist";
30
+ const builtInTypeMessage = "type: collection is built in and cannot be created, changed or deleted";
31
+ const COLLECTION_TYPE = "collection";
32
+ /** The built-in type whose records group other records, as folders do. */
33
+ const collectionDefinition = {
34
+ description: "Collections group records the way folders do. A record joins a collection through its collections list, not through a field.",
35
+ title: "Collection",
36
+ titleField: "title",
37
+ schema: {
38
+ type: "object",
39
+ required: ["title"],
40
+ properties: {
41
+ title: { type: "string", description: "The name of the collection, as shown in a folder list." },
42
+ description: { type: "string", description: "What belongs in the collection." },
43
+ },
44
+ },
45
+ };
29
46
  const clockIdentifier = BigInt(randomBytes(2).readUInt16BE() & 0x3ff);
30
47
  let lastTidTimestamp = 0n;
31
48
  /** A refused operation, with every problem the caller can correct. */
@@ -55,8 +72,8 @@ export class Autofile {
55
72
  refuse("folder: must be a non-empty string");
56
73
  }
57
74
  this.#dataFolder = resolve(options.folder);
58
- this.#typeFolder = join(this.#dataFolder, "_types");
59
- this.#blobFolder = join(this.#dataFolder, "_blobs");
75
+ this.#typeFolder = join(this.#dataFolder, "types");
76
+ this.#blobFolder = join(this.#dataFolder, "blobs");
60
77
  // Keep the existing process-home lock policy until a separate change moves it.
61
78
  this.#writeLockPath = resolve(getWriteLockPath());
62
79
  }
@@ -70,13 +87,18 @@ export class Autofile {
70
87
  if (problem !== undefined)
71
88
  return refuse(`record: ${problem}`);
72
89
  // Snapshot checked JSON before asynchronous storage work begins.
73
- return this.#createRecord(type, JSON.parse(JSON.stringify(value)), { key: options.key });
90
+ return this.#createRecord(type, JSON.parse(JSON.stringify(value)), {
91
+ key: options.key,
92
+ collections: snapshotCollections(options.collections),
93
+ });
74
94
  }),
75
- update: (uri, patch) => this.#operation(() => {
95
+ update: (uri, patch, options = {}) => this.#operation(() => {
96
+ if (!isJsonObject(options))
97
+ return refuse("options: must be an object", uri);
76
98
  const problem = jsonObjectProblem(patch);
77
99
  if (problem !== undefined)
78
100
  return refuse(`patch: ${problem}`, uri);
79
- return this.#updateRecord(uri, JSON.parse(JSON.stringify(patch)));
101
+ return this.#updateRecord(uri, JSON.parse(JSON.stringify(patch)), snapshotCollections(options.collections));
80
102
  }),
81
103
  delete: (uri) => this.#operation(() => this.#deleteRecord(uri)),
82
104
  };
@@ -91,16 +113,21 @@ export class Autofile {
91
113
  put: (bytes, mediaType) => this.#operation(() => {
92
114
  if (!(bytes instanceof Uint8Array))
93
115
  return refuse("bytes: must be a Uint8Array");
94
- if (bytes.byteLength > MAX_BLOB_BYTES)
95
- return refuse(blobSizeProblem(bytes.byteLength));
96
116
  if (!isMediaType(mediaType))
97
117
  return refuse("mediaType: must be a valid media type, such as application/pdf");
98
118
  const snapshot = Buffer.from(bytes);
99
- if (snapshot.length > MAX_BLOB_BYTES)
100
- return refuse(blobSizeProblem(snapshot.length));
101
119
  return this.#withWriteLock(() => writeBlob(this.#blobFolder, snapshot, mediaType));
102
120
  }),
121
+ putStream: (chunks, mediaType) => this.#operation(() => {
122
+ if (typeof chunks?.[Symbol.asyncIterator] !== "function")
123
+ return refuse("chunks: must be an AsyncIterable of Uint8Array chunks");
124
+ if (!isMediaType(mediaType))
125
+ return refuse("mediaType: must be a valid media type, such as application/pdf");
126
+ return this.#withWriteLock(() => writeBlobStream(this.#blobFolder, snapshotBlobChunks(chunks), mediaType));
127
+ }),
103
128
  get: (uri) => this.#operation(() => this.#getBlob(uri)),
129
+ stat: (uri) => this.#operation(() => this.#statBlob(uri)),
130
+ stream: (uri, range) => this.#operation(() => this.#streamBlob(uri, range)),
104
131
  };
105
132
  /** Checks a schema without accessing storage or checking link targets. */
106
133
  static async prepareSchema(schema) {
@@ -202,9 +229,12 @@ export class Autofile {
202
229
  const uncertainTypes = new Set();
203
230
  const events = [];
204
231
  for (const change of changes) {
205
- if (!change.uri.startsWith("autofile:///_types/"))
232
+ if (!change.uri.startsWith("autofile:///types/"))
233
+ continue;
234
+ const type = change.uri.slice("autofile:///types/".length);
235
+ // The built-in declaration never changes; a file at its path is ignored.
236
+ if (type === COLLECTION_TYPE)
206
237
  continue;
207
- const type = change.uri.slice("autofile:///_types/".length);
208
238
  try {
209
239
  const declaration = await readValidDeclaration(this.#typeFolder, type);
210
240
  if (declaration === undefined)
@@ -270,6 +300,11 @@ export class Autofile {
270
300
  if (input.q !== undefined && typeof input.q !== "string") {
271
301
  return refuse("q: must be a string");
272
302
  }
303
+ const collection = input.collection;
304
+ if (collection !== undefined && collection !== null
305
+ && (typeof collection !== "string" || !collectionUriPattern.test(collection))) {
306
+ return refuse("collection: must be a collection URI or null");
307
+ }
273
308
  if (input.where !== undefined && !isJsonObject(input.where)) {
274
309
  return refuse("where: must be a JSON object");
275
310
  }
@@ -295,6 +330,9 @@ export class Autofile {
295
330
  if (input.cursor !== undefined && afterUri === undefined) {
296
331
  return refuse("cursor: is invalid");
297
332
  }
333
+ if (typeof collection === "string" && !await recordExists(this.#dataFolder, this.#typeFolder, collection)) {
334
+ return refuse("collection does not exist", collection);
335
+ }
298
336
  const declarations = requestedTypes === undefined
299
337
  ? await this.#listTypes()
300
338
  : await Promise.all([...new Set(requestedTypes)].map(async (type) => {
@@ -306,7 +344,7 @@ export class Autofile {
306
344
  const entries = [];
307
345
  for (const declaration of declarations) {
308
346
  const schema = await preparedDeclarationSchema(declaration, declaration.uri);
309
- for (const entry of await recognizedRecordEntries(join(this.#dataFolder, declaration.type))) {
347
+ for (const entry of await recognizedRecordEntries(recordFolder(this.#dataFolder, declaration.type))) {
310
348
  const key = entry.name.slice(0, -".json".length);
311
349
  entries.push({ type: declaration.type, key, name: entry.name, uri: recordUri(declaration.type, key), declaration, schema });
312
350
  }
@@ -333,7 +371,7 @@ export class Autofile {
333
371
  let record;
334
372
  let resultError;
335
373
  try {
336
- stored = await readStoredRecord(join(this.#dataFolder, type, name));
374
+ stored = await readStoredRecord(join(recordFolder(this.#dataFolder, type), name));
337
375
  }
338
376
  catch (error) {
339
377
  resultError = { uri, message: recordReadError(error) };
@@ -345,7 +383,8 @@ export class Autofile {
345
383
  message: stored.status === "invalid" ? stored.message : "record does not exist",
346
384
  };
347
385
  }
348
- else if (stringValuesContainWords(stored.value.value, words)
386
+ else if (inCollection(stored.value, collection)
387
+ && stringValuesContainWords(stored.value.value, words)
349
388
  && query.test(stored.value.value)) {
350
389
  const validationErrors = await schema.validate(stored.value.value);
351
390
  if (validationErrors.length > 0) {
@@ -435,7 +474,10 @@ export class Autofile {
435
474
  const linkErrors = await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, sourceUri);
436
475
  if (linkErrors.length > 0)
437
476
  throw new AutofileError(linkErrors);
438
- await mkdir(join(this.#dataFolder, type), { recursive: true });
477
+ if (options.collections !== undefined) {
478
+ await this.#checkCollections(options.collections, sourceUri);
479
+ }
480
+ await mkdir(recordFolder(this.#dataFolder, type), { recursive: true });
439
481
  let key = options.key;
440
482
  if (key === undefined) {
441
483
  do
@@ -450,12 +492,12 @@ export class Autofile {
450
492
  if (blobErrors.length > 0)
451
493
  throw new AutofileError(blobErrors);
452
494
  const now = new Date().toISOString();
453
- const stored = { createdAt: now, updatedAt: now, value };
495
+ const stored = storedRecord(now, now, options.collections, value);
454
496
  await writeJsonAtomically(recordPath(this.#dataFolder, type, key), stored);
455
497
  return recordResult(type, key, declaration.value, stored);
456
498
  });
457
499
  }
458
- async #updateRecord(uri, patch) {
500
+ async #updateRecord(uri, patch, collections) {
459
501
  return this.#withWriteLock(async () => {
460
502
  const parsed = parseRecordUri(uri);
461
503
  if (parsed === undefined)
@@ -484,12 +526,10 @@ export class Autofile {
484
526
  ];
485
527
  if (errors.length > 0)
486
528
  throw new AutofileError(errors);
529
+ if (collections !== undefined)
530
+ await this.#checkCollections(collections, uri);
487
531
  const previousBlobs = await blobsHeldByRecord(stored.value.value, schema);
488
- const updated = {
489
- createdAt: stored.value.createdAt,
490
- updatedAt: new Date().toISOString(),
491
- value,
492
- };
532
+ const updated = storedRecord(stored.value.createdAt, timestampAfter(stored.value.updatedAt), collections ?? stored.value.collections, value);
493
533
  await writeJsonAtomically(path, updated);
494
534
  await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
495
535
  return recordResult(parsed.type, parsed.key, declaration.value, updated);
@@ -524,25 +564,19 @@ export class Autofile {
524
564
  catch (error) {
525
565
  errors.push({ uri: targetUri, message: recordReadError(error) });
526
566
  }
527
- const typeEntries = (await readDirectory(this.#typeFolder))
528
- .filter((entry) => (entry.isFile()
529
- && entry.name.endsWith(".json")
530
- && typeNamePattern.test(entry.name.slice(0, -".json".length))))
531
- .sort((left, right) => left.name.localeCompare(right.name));
532
- for (const typeEntry of typeEntries) {
533
- const type = typeEntry.name.slice(0, -".json".length);
567
+ for (const type of await storedTypeNames(this.#typeFolder)) {
534
568
  const otherDeclaration = await readValidDeclaration(this.#typeFolder, type);
535
569
  if (otherDeclaration === undefined)
536
570
  continue;
537
571
  const schema = await preparedDeclarationSchema(otherDeclaration, typeUri(type));
538
- for (const entry of await recognizedRecordEntries(join(this.#dataFolder, type))) {
572
+ for (const entry of await recognizedRecordEntries(recordFolder(this.#dataFolder, type))) {
539
573
  const key = entry.name.slice(0, -".json".length);
540
574
  const uri = recordUri(type, key);
541
575
  if (uri === targetUri)
542
576
  continue;
543
577
  let stored;
544
578
  try {
545
- stored = await readStoredRecord(join(this.#dataFolder, type, entry.name));
579
+ stored = await readStoredRecord(join(recordFolder(this.#dataFolder, type), entry.name));
546
580
  }
547
581
  catch (error) {
548
582
  errors.push({ uri, message: recordReadError(error) });
@@ -555,34 +589,37 @@ export class Autofile {
555
589
  });
556
590
  continue;
557
591
  }
558
- const validationErrors = await schema.validate(stored.value.value);
592
+ const collections = stored.value.collections?.filter((collection) => collection !== targetUri);
593
+ const leavesCollection = collections?.length !== stored.value.collections?.length;
594
+ let value = stored.value.value;
595
+ const validationErrors = await schema.validate(value);
559
596
  if (validationErrors.length > 0) {
560
- if (containsStringValue(stored.value.value, targetUri)) {
597
+ if (containsStringValue(value, targetUri)) {
561
598
  errors.push({
562
599
  uri,
563
600
  message: `record does not match its type and holds ${targetUri} as a string value`,
564
601
  });
602
+ continue;
565
603
  }
566
- continue;
567
604
  }
568
- const pointers = schema.formatFields(stored.value.value, "uri")
569
- .filter((field) => field.value === targetUri)
570
- .map(({ pointer }) => pointer);
571
- if (pointers.length === 0)
572
- continue;
573
- const value = removeFields(stored.value.value, pointers);
574
- const remainingErrors = await schema.validate(value);
575
- if (remainingErrors.length > 0) {
576
- errors.push({ uri, message: remainingErrors.join("; ") });
577
- continue;
605
+ else {
606
+ const pointers = schema.formatFields(value, "uri")
607
+ .filter((field) => field.value === targetUri)
608
+ .map(({ pointer }) => pointer);
609
+ if (pointers.length > 0) {
610
+ value = removeFields(value, pointers);
611
+ const remainingErrors = await schema.validate(value);
612
+ if (remainingErrors.length > 0) {
613
+ errors.push({ uri, message: remainingErrors.join("; ") });
614
+ continue;
615
+ }
616
+ }
578
617
  }
618
+ if (value === stored.value.value && !leavesCollection)
619
+ continue;
579
620
  changes.push({
580
- path: join(this.#dataFolder, type, entry.name),
581
- record: {
582
- createdAt: stored.value.createdAt,
583
- updatedAt: timestampAfter(stored.value.updatedAt),
584
- value,
585
- },
621
+ path: join(recordFolder(this.#dataFolder, type), entry.name),
622
+ record: storedRecord(stored.value.createdAt, timestampAfter(stored.value.updatedAt), collections, value),
586
623
  });
587
624
  }
588
625
  }
@@ -612,20 +649,41 @@ export class Autofile {
612
649
  await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
613
650
  });
614
651
  }
652
+ /** Refuses a collection list naming anything but existing collections, or placing a collection inside itself. */
653
+ async #checkCollections(collections, uri) {
654
+ for (const collection of collections) {
655
+ if (!await recordExists(this.#dataFolder, this.#typeFolder, collection)) {
656
+ refuse(`collections: ${collection} is not a collection that exists`, uri);
657
+ }
658
+ }
659
+ if (uri === undefined || parseRecordUri(uri)?.type !== COLLECTION_TYPE)
660
+ return;
661
+ const visited = new Set();
662
+ const pending = [...collections];
663
+ while (pending.length > 0) {
664
+ const current = pending.pop();
665
+ if (current === uri)
666
+ refuse(`collections: would put ${uri} inside itself`, uri);
667
+ if (visited.has(current))
668
+ continue;
669
+ visited.add(current);
670
+ const parsed = parseRecordUri(current);
671
+ if (parsed === undefined)
672
+ continue;
673
+ const stored = await readStoredRecord(recordPath(this.#dataFolder, parsed.type, parsed.key));
674
+ if (stored.status === "found")
675
+ pending.push(...stored.value.collections ?? []);
676
+ }
677
+ }
615
678
  async #listTypes() {
616
679
  await this.#ensureReady();
617
680
  const types = [];
618
- for (const entry of await readDirectory(this.#typeFolder)) {
619
- if (!entry.isFile() || !entry.name.endsWith(".json"))
620
- continue;
621
- const type = entry.name.slice(0, -".json".length);
622
- if (!typeNamePattern.test(type))
623
- continue;
681
+ for (const type of await storedTypeNames(this.#typeFolder)) {
624
682
  const declaration = await readValidDeclaration(this.#typeFolder, type);
625
683
  if (declaration !== undefined)
626
684
  types.push(typeResult(type, declaration));
627
685
  }
628
- return types.sort((left, right) => left.type.localeCompare(right.type));
686
+ return types;
629
687
  }
630
688
  async #getType(type) {
631
689
  await this.#ensureReady();
@@ -642,6 +700,8 @@ export class Autofile {
642
700
  async #createType(type, definition) {
643
701
  if (typeof type !== "string" || !typeNamePattern.test(type))
644
702
  return refuse(invalidTypeMessage);
703
+ if (type === COLLECTION_TYPE)
704
+ return refuse(builtInTypeMessage, typeUri(type));
645
705
  if (!isJsonObject(definition))
646
706
  return refuse("declaration: must be an object");
647
707
  const { description, titleField } = definition;
@@ -669,7 +729,7 @@ export class Autofile {
669
729
  await prepareDeclarationSchema(declaration.schema, typeUri(type));
670
730
  await Promise.all([
671
731
  mkdir(this.#typeFolder, { recursive: true }),
672
- mkdir(join(this.#dataFolder, type), { recursive: true }),
732
+ mkdir(recordFolder(this.#dataFolder, type), { recursive: true }),
673
733
  ]);
674
734
  await writeJsonAtomically(declarationPath, declaration);
675
735
  return typeResult(type, declaration);
@@ -678,6 +738,8 @@ export class Autofile {
678
738
  async #updateType(type, changes) {
679
739
  if (typeof type !== "string" || !typeNamePattern.test(type))
680
740
  return refuse(invalidTypeMessage);
741
+ if (type === COLLECTION_TYPE)
742
+ return refuse(builtInTypeMessage, typeUri(type));
681
743
  if (!isJsonObject(changes))
682
744
  return refuse("changes: must be an object");
683
745
  const { description, titleField: changedTitleField, schema: changedSchema } = changes;
@@ -723,11 +785,13 @@ export class Autofile {
723
785
  return this.#withWriteLock(async () => {
724
786
  if (typeof type !== "string" || !typeNamePattern.test(type))
725
787
  return refuse(invalidTypeMessage);
788
+ if (type === COLLECTION_TYPE)
789
+ return refuse(builtInTypeMessage, typeUri(type));
726
790
  const declaration = await readDeclaration(this.#typeFolder, type);
727
791
  if (declaration.status !== "found")
728
792
  return declarationRefusal(type, declaration);
729
793
  try {
730
- await rmdir(join(this.#dataFolder, type));
794
+ await rmdir(recordFolder(this.#dataFolder, type));
731
795
  }
732
796
  catch (error) {
733
797
  // Keep the declaration whenever any stored data remains, including
@@ -746,9 +810,53 @@ export class Autofile {
746
810
  const name = blobNameFromUri(uri);
747
811
  if (name === undefined || !await isDirectory(this.#blobFolder))
748
812
  return undefined;
749
- const bytes = await readRegularFile(join(this.#blobFolder, name), MAX_BLOB_BYTES);
813
+ const bytes = await readRegularFile(join(this.#blobFolder, name));
750
814
  return bytes === undefined ? undefined : { bytes, mimeType: mimeTypeForBlob(name) };
751
815
  }
816
+ async #statBlob(uri) {
817
+ await this.#ensureReady();
818
+ const name = blobNameFromUri(uri);
819
+ if (name === undefined || !await isDirectory(this.#blobFolder))
820
+ return undefined;
821
+ const path = join(this.#blobFolder, name);
822
+ try {
823
+ const details = await lstat(path);
824
+ return details.isFile() ? { size: details.size, mimeType: mimeTypeForBlob(name), path } : undefined;
825
+ }
826
+ catch (error) {
827
+ if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR"))
828
+ return undefined;
829
+ throw error;
830
+ }
831
+ }
832
+ async #streamBlob(uri, range) {
833
+ await this.#ensureReady();
834
+ const name = blobNameFromUri(uri);
835
+ if (name === undefined || !await isDirectory(this.#blobFolder))
836
+ return undefined;
837
+ // Read caller-controlled offsets before opening a file: an accessor may throw.
838
+ const start = range === undefined ? 0 : range?.start;
839
+ const requestedEnd = range?.end;
840
+ const file = await openRegularFile(join(this.#blobFolder, name));
841
+ if (file === undefined)
842
+ return undefined;
843
+ const end = range === undefined ? file.size : requestedEnd;
844
+ if (typeof start !== "number" || typeof end !== "number"
845
+ || !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
846
+ || start < 0 || end < start || end > file.size) {
847
+ await file.handle.close();
848
+ return refuse("range: must contain integer offsets with 0 <= start <= end <= blob size", uri);
849
+ }
850
+ return streamFile(file.handle, start, end);
851
+ }
852
+ }
853
+ /** Copies one chunk before asynchronous writes can observe caller mutations. */
854
+ async function* snapshotBlobChunks(chunks) {
855
+ for await (const chunk of chunks) {
856
+ if (!(chunk instanceof Uint8Array))
857
+ refuse("chunks: each chunk must be a Uint8Array");
858
+ yield Buffer.from(chunk);
859
+ }
752
860
  }
753
861
  function recordResult(type, key, declaration, stored) {
754
862
  const title = declaration.titleField === undefined ? undefined : stored.value[declaration.titleField];
@@ -850,23 +958,20 @@ async function deleteUnheldBlobs(dataFolder, typeFolder, candidates) {
850
958
  const held = await blobsHeldInStore(dataFolder, typeFolder);
851
959
  if (held === undefined)
852
960
  return;
853
- await deleteBlobUris(join(dataFolder, "_blobs"), [...candidates].filter((uri) => !held.has(uri)));
961
+ await deleteBlobUris(join(dataFolder, "blobs"), [...candidates].filter((uri) => !held.has(uri)));
854
962
  }
855
963
  async function blobsHeldInStore(dataFolder, typeFolder) {
856
964
  const held = new Set();
857
- let typeEntries;
965
+ let types;
858
966
  try {
859
- typeEntries = (await readDirectory(typeFolder)).filter((entry) => (entry.isFile() && entry.name.endsWith(".json")));
967
+ types = await storedTypeNames(typeFolder);
860
968
  }
861
969
  catch (error) {
862
970
  if (hasErrorCode(error))
863
971
  return undefined;
864
972
  throw error;
865
973
  }
866
- for (const typeEntry of typeEntries) {
867
- const type = typeEntry.name.slice(0, -".json".length);
868
- if (!typeNamePattern.test(type))
869
- continue;
974
+ for (const type of types) {
870
975
  let declaration;
871
976
  try {
872
977
  declaration = await readDeclaration(typeFolder, type);
@@ -888,7 +993,7 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
888
993
  }
889
994
  let recordEntries;
890
995
  try {
891
- recordEntries = await recognizedRecordEntries(join(dataFolder, type));
996
+ recordEntries = await recognizedRecordEntries(recordFolder(dataFolder, type));
892
997
  }
893
998
  catch (error) {
894
999
  if (hasErrorCode(error))
@@ -898,7 +1003,7 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
898
1003
  for (const entry of recordEntries) {
899
1004
  let record;
900
1005
  try {
901
- record = await readJson(join(dataFolder, type, entry.name), "record");
1006
+ record = await readJson(join(recordFolder(dataFolder, type), entry.name), "record");
902
1007
  }
903
1008
  catch (error) {
904
1009
  if (hasErrorCode(error))
@@ -965,13 +1070,13 @@ async function readValidDeclaration(typeFolder, type) {
965
1070
  }
966
1071
  async function invalidRecords(dataFolder, type, schema) {
967
1072
  const errors = [];
968
- const entries = await recognizedRecordEntries(join(dataFolder, type));
1073
+ const entries = await recognizedRecordEntries(recordFolder(dataFolder, type));
969
1074
  for (const entry of entries) {
970
1075
  const key = entry.name.slice(0, -".json".length);
971
1076
  const uri = recordUri(type, key);
972
1077
  let record;
973
1078
  try {
974
- record = await readStoredRecord(join(dataFolder, type, entry.name));
1079
+ record = await readStoredRecord(join(recordFolder(dataFolder, type), entry.name));
975
1080
  }
976
1081
  catch (error) {
977
1082
  errors.push({ uri, message: recordReadError(error) });
@@ -988,6 +1093,8 @@ async function invalidRecords(dataFolder, type, schema) {
988
1093
  return errors;
989
1094
  }
990
1095
  async function readDeclaration(folder, type) {
1096
+ if (type === COLLECTION_TYPE)
1097
+ return { status: "found", value: collectionDefinition };
991
1098
  if (!await isDirectory(folder))
992
1099
  return { status: "missing" };
993
1100
  const result = await readJson(join(folder, `${type}.json`), "type declaration");
@@ -999,7 +1106,7 @@ async function readDeclaration(folder, type) {
999
1106
  return { status: "found", value: result.value };
1000
1107
  }
1001
1108
  async function readStoredRecord(path) {
1002
- if (!await isDirectory(dirname(path)))
1109
+ if (!await isDirectory(dirname(dirname(path))) || !await isDirectory(dirname(path)))
1003
1110
  return { status: "missing" };
1004
1111
  const result = await readJson(path, "record");
1005
1112
  if (result.status !== "found")
@@ -1084,16 +1191,19 @@ function parseRecordUri(uri) {
1084
1191
  return { type: match[1], key: match[2] };
1085
1192
  }
1086
1193
  function recordUri(type, key) {
1087
- return `autofile:///${type}/${key}`;
1194
+ return `autofile:///records/${type}/${key}`;
1088
1195
  }
1089
1196
  function typeUri(type) {
1090
- return `autofile:///_types/${type}`;
1197
+ return `autofile:///types/${type}`;
1091
1198
  }
1092
1199
  function mimeTypeForBlob(name) {
1093
1200
  return lookupMimeType(name) || "application/octet-stream";
1094
1201
  }
1095
1202
  function recordPath(dataFolder, type, key) {
1096
- return join(dataFolder, type, `${key}.json`);
1203
+ return join(recordFolder(dataFolder, type), `${key}.json`);
1204
+ }
1205
+ function recordFolder(dataFolder, type) {
1206
+ return join(dataFolder, "records", type);
1097
1207
  }
1098
1208
  function encodeRecordCursor(uri) {
1099
1209
  return Buffer.from(uri, "utf8").toString("base64url");
@@ -1253,6 +1363,8 @@ function recordReadRefusal(uri, result) {
1253
1363
  throw new Error("A found record is not a refusal");
1254
1364
  }
1255
1365
  async function recognizedRecordEntries(folder) {
1366
+ if (!await isDirectory(dirname(folder)))
1367
+ return [];
1256
1368
  return (await readDirectory(folder))
1257
1369
  .filter((entry) => {
1258
1370
  if (!entry.isFile() || !entry.name.endsWith(".json"))
@@ -1321,15 +1433,45 @@ async function openRegularFile(path) {
1321
1433
  throw error;
1322
1434
  }
1323
1435
  }
1324
- async function readRegularFile(path, maximumBytes = Number.POSITIVE_INFINITY) {
1436
+ /** Keeps a file open only while a bounded, backpressured read is active. */
1437
+ function streamFile(handle, start, end) {
1438
+ let position = start;
1439
+ let closing;
1440
+ const close = () => closing ??= handle.close().catch(() => { throw new Error("Storage operation failed"); });
1441
+ return new ReadableStream({
1442
+ async pull(controller) {
1443
+ try {
1444
+ if (position === end) {
1445
+ await close();
1446
+ controller.close();
1447
+ return;
1448
+ }
1449
+ const bytes = Buffer.allocUnsafe(Math.min(64 * 1024, end - position));
1450
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, position);
1451
+ if (closing !== undefined)
1452
+ return;
1453
+ if (bytesRead === 0) {
1454
+ await close();
1455
+ controller.close();
1456
+ return;
1457
+ }
1458
+ position += bytesRead;
1459
+ controller.enqueue(bytes.subarray(0, bytesRead));
1460
+ }
1461
+ catch {
1462
+ await close();
1463
+ controller.error(new Error("Storage operation failed"));
1464
+ }
1465
+ },
1466
+ cancel: () => close(),
1467
+ });
1468
+ }
1469
+ async function readRegularFile(path) {
1325
1470
  const file = await openRegularFile(path);
1326
1471
  if (file === undefined)
1327
1472
  return undefined;
1328
1473
  try {
1329
- if (file.size > maximumBytes)
1330
- return undefined;
1331
- const bytes = await file.handle.readFile();
1332
- return bytes.byteLength <= maximumBytes ? bytes : undefined;
1474
+ return await file.handle.readFile();
1333
1475
  }
1334
1476
  finally {
1335
1477
  await file.handle.close();
@@ -1362,12 +1504,60 @@ function isTypeDefinition(value) {
1362
1504
  function isStoredRecord(value) {
1363
1505
  if (!isJsonObject(value))
1364
1506
  return false;
1365
- if (Object.keys(value).length !== 3)
1507
+ const hasCollections = Object.hasOwn(value, "collections");
1508
+ if (Object.keys(value).length !== (hasCollections ? 4 : 3))
1366
1509
  return false;
1367
1510
  if (!["createdAt", "updatedAt", "value"].every((key) => Object.hasOwn(value, key)))
1368
1511
  return false;
1512
+ if (hasCollections && (collectionsProblem(value.collections) !== undefined || value.collections.length === 0)) {
1513
+ return false;
1514
+ }
1369
1515
  return isTimestamp(value.createdAt) && isTimestamp(value.updatedAt) && isJsonObject(value.value);
1370
1516
  }
1517
+ /** Says what is wrong with a list of collection URIs, ignoring whether they exist. */
1518
+ function collectionsProblem(value) {
1519
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1520
+ return "collections: must be an array of collection URIs";
1521
+ }
1522
+ const other = value.find((item) => !collectionUriPattern.test(item));
1523
+ if (other !== undefined)
1524
+ return `collections: ${other} is not a collection URI`;
1525
+ if (new Set(value).size !== value.length)
1526
+ return "collections: must not list a collection more than once";
1527
+ return undefined;
1528
+ }
1529
+ /** Checks and copies a caller's collection list before asynchronous work. */
1530
+ function snapshotCollections(value) {
1531
+ if (value === undefined)
1532
+ return undefined;
1533
+ const problem = collectionsProblem(value);
1534
+ if (problem !== undefined)
1535
+ refuse(problem);
1536
+ return [...value];
1537
+ }
1538
+ /** Builds a stored record, leaving out an empty collection list. */
1539
+ function storedRecord(createdAt, updatedAt, collections, value) {
1540
+ return { createdAt, updatedAt, ...(collections?.length ? { collections } : {}), value };
1541
+ }
1542
+ function inCollection(record, collection) {
1543
+ if (collection === undefined)
1544
+ return true;
1545
+ if (collection === null)
1546
+ return record.collections === undefined;
1547
+ return record.collections?.includes(collection) === true;
1548
+ }
1549
+ /** Every type with a declaration file, plus the built-in collection type, ordered by name. */
1550
+ async function storedTypeNames(typeFolder) {
1551
+ const names = new Set([COLLECTION_TYPE]);
1552
+ for (const entry of await readDirectory(typeFolder)) {
1553
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
1554
+ continue;
1555
+ const type = entry.name.slice(0, -".json".length);
1556
+ if (typeNamePattern.test(type))
1557
+ names.add(type);
1558
+ }
1559
+ return [...names].sort((left, right) => left.localeCompare(right));
1560
+ }
1371
1561
  function isTimestamp(value) {
1372
1562
  if (typeof value !== "string" || !timestampPattern.test(value))
1373
1563
  return false;