@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/README.md +57 -38
- package/dist/blobs.d.ts +4 -6
- package/dist/blobs.js +48 -81
- package/dist/blobs.js.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +131 -5
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/mcp-resources.d.ts +5 -1
- package/dist/mcp-resources.js +20 -4
- package/dist/mcp-resources.js.map +1 -1
- package/dist/mcp-tools.d.ts +4 -1
- package/dist/mcp-tools.js +11 -8
- package/dist/mcp-tools.js.map +1 -1
- package/dist/mcp-upload.d.ts +1 -1
- package/dist/mcp-upload.js +29 -29
- package/dist/mcp-upload.js.map +1 -1
- package/dist/mcp-watch.js +3 -3
- package/dist/mcp-watch.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +26 -9
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +21 -1
- package/dist/store.js +274 -84
- package/dist/store.js.map +1 -1
- package/dist/watch.js +21 -24
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
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 {
|
|
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, "
|
|
59
|
-
this.#blobFolder = join(this.#dataFolder, "
|
|
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)), {
|
|
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:///
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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)
|
|
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, "
|
|
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
|
|
965
|
+
let types;
|
|
858
966
|
try {
|
|
859
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
1194
|
+
return `autofile:///records/${type}/${key}`;
|
|
1088
1195
|
}
|
|
1089
1196
|
function typeUri(type) {
|
|
1090
|
-
return `autofile:///
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|