@telepath-computer/autofile 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -36
- package/dist/blobs.d.ts +9 -24
- package/dist/blobs.js +60 -229
- package/dist/blobs.js.map +1 -1
- package/dist/cli.js +16 -10
- package/dist/cli.js.map +1 -1
- package/dist/http.d.ts +3 -3
- package/dist/http.js +141 -10
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/json.d.ts +3 -1
- package/dist/json.js +81 -2
- package/dist/json.js.map +1 -1
- package/dist/mcp-resources.d.ts +35 -0
- package/dist/mcp-resources.js +127 -0
- package/dist/mcp-resources.js.map +1 -0
- package/dist/mcp-tools.d.ts +113 -0
- package/dist/mcp-tools.js +89 -0
- package/dist/mcp-tools.js.map +1 -0
- package/dist/mcp-upload.d.ts +16 -0
- package/dist/mcp-upload.js +81 -0
- package/dist/mcp-upload.js.map +1 -0
- package/dist/mcp-watch.d.ts +18 -0
- package/dist/mcp-watch.js +165 -0
- package/dist/mcp-watch.js.map +1 -0
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +8 -2
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +3 -2
- package/dist/server.js +58 -38
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +102 -108
- package/dist/store.js +622 -530
- package/dist/store.js.map +1 -1
- package/dist/watch.d.ts +6 -18
- package/dist/watch.js +92 -213
- package/dist/watch.js.map +1 -1
- package/package.json +7 -1
package/dist/store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Autofile owns one data folder: record and type operations, blob uploads and
|
|
3
|
+
* reads, and shared change subscriptions. Operations read current files;
|
|
4
|
+
* protocol adapters translate these values without owning a second persistence
|
|
5
|
+
* implementation.
|
|
5
6
|
*/
|
|
6
7
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
7
8
|
import { constants } from "node:fs";
|
|
@@ -11,41 +12,236 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
11
12
|
import { lookup as lookupMimeType } from "mime-types";
|
|
12
13
|
import { Query } from "mingo";
|
|
13
14
|
import lockfile from "proper-lockfile";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
15
|
+
import { blobNameFromUri, deleteBlobUris, blobReferenceProblem, isMediaType, writeBlob, writeBlobStream, } from "./blobs.js";
|
|
16
|
+
import { getWriteLockPath } from "./config.js";
|
|
17
|
+
import { jsonObjectProblem } from "./json.js";
|
|
16
18
|
import { prepareSchema, SchemaRefusal } from "./schema.js";
|
|
19
|
+
import { watchFolder } from "./watch.js";
|
|
17
20
|
const typeNamePattern = /^[a-z][a-z0-9-]*$/u;
|
|
18
21
|
const keyPattern = /^[a-z0-9][a-z0-9-]*$/u;
|
|
19
|
-
const recordUriPattern = /^autofile:\/\/\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/u;
|
|
20
|
-
const typeUriPattern = /^autofile:\/\/\/_types\/([a-z][a-z0-9-]*)$/u;
|
|
22
|
+
const recordUriPattern = /^autofile:\/\/\/records\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/u;
|
|
21
23
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
|
|
22
24
|
const MAX_FIND_RECORDS_PAGE_BYTES = 64 * 1024;
|
|
23
25
|
const tidAlphabet = "234567abcdefghijklmnopqrstuvwxyz";
|
|
24
26
|
const invalidTypeMessage = "type: must use lowercase letters, digits, and hyphens and start with a letter";
|
|
25
27
|
const invalidKeyMessage = "key: must use lowercase letters, digits, and hyphens and start with a letter or digit";
|
|
26
|
-
const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///task/book-the-dentist";
|
|
28
|
+
const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///records/task/book-the-dentist";
|
|
27
29
|
const clockIdentifier = BigInt(randomBytes(2).readUInt16BE() & 0x3ff);
|
|
28
30
|
let lastTidTimestamp = 0n;
|
|
29
|
-
/**
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return createStore(dataFolder, writeLockPath);
|
|
31
|
+
/** A refused operation, with every problem the caller can correct. */
|
|
32
|
+
export class AutofileError extends Error {
|
|
33
|
+
problems;
|
|
34
|
+
constructor(problems) {
|
|
35
|
+
super(problems.map(({ message }) => message).join("; "));
|
|
36
|
+
this.name = "AutofileError";
|
|
37
|
+
this.problems = problems;
|
|
38
|
+
}
|
|
38
39
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/** The store implementation. Construction resolves paths but performs no I/O. */
|
|
41
|
+
export class Autofile {
|
|
42
|
+
#dataFolder;
|
|
43
|
+
#typeFolder;
|
|
44
|
+
#blobFolder;
|
|
45
|
+
#writeLockPath;
|
|
46
|
+
#ready;
|
|
47
|
+
#closed = false;
|
|
48
|
+
#closing;
|
|
49
|
+
#watcher;
|
|
50
|
+
#notifications = Promise.resolve();
|
|
51
|
+
#listeners = new Set();
|
|
52
|
+
#watchTypes = new Set();
|
|
53
|
+
constructor(options) {
|
|
54
|
+
if (typeof options?.folder !== "string" || options.folder.length === 0) {
|
|
55
|
+
refuse("folder: must be a non-empty string");
|
|
56
|
+
}
|
|
57
|
+
this.#dataFolder = resolve(options.folder);
|
|
58
|
+
this.#typeFolder = join(this.#dataFolder, "types");
|
|
59
|
+
this.#blobFolder = join(this.#dataFolder, "blobs");
|
|
60
|
+
// Keep the existing process-home lock policy until a separate change moves it.
|
|
61
|
+
this.#writeLockPath = resolve(getWriteLockPath());
|
|
62
|
+
}
|
|
63
|
+
records = {
|
|
64
|
+
find: (query = {}) => this.#operation(() => this.#findRecords(query)),
|
|
65
|
+
get: (uri) => this.#operation(() => this.#getRecord(uri)),
|
|
66
|
+
create: (type, value, options = {}) => this.#operation(() => {
|
|
67
|
+
if (!isJsonObject(options))
|
|
68
|
+
return refuse("options: must be an object");
|
|
69
|
+
const problem = jsonObjectProblem(value);
|
|
70
|
+
if (problem !== undefined)
|
|
71
|
+
return refuse(`record: ${problem}`);
|
|
72
|
+
// Snapshot checked JSON before asynchronous storage work begins.
|
|
73
|
+
return this.#createRecord(type, JSON.parse(JSON.stringify(value)), { key: options.key });
|
|
74
|
+
}),
|
|
75
|
+
update: (uri, patch) => this.#operation(() => {
|
|
76
|
+
const problem = jsonObjectProblem(patch);
|
|
77
|
+
if (problem !== undefined)
|
|
78
|
+
return refuse(`patch: ${problem}`, uri);
|
|
79
|
+
return this.#updateRecord(uri, JSON.parse(JSON.stringify(patch)));
|
|
80
|
+
}),
|
|
81
|
+
delete: (uri) => this.#operation(() => this.#deleteRecord(uri)),
|
|
82
|
+
};
|
|
83
|
+
types = {
|
|
84
|
+
list: () => this.#operation(() => this.#listTypes()),
|
|
85
|
+
get: (name) => this.#operation(() => this.#getType(name)),
|
|
86
|
+
create: (name, definition) => this.#operation(() => this.#createType(name, definition)),
|
|
87
|
+
update: (name, changes) => this.#operation(() => this.#updateType(name, changes)),
|
|
88
|
+
delete: (name) => this.#operation(() => this.#deleteType(name)),
|
|
89
|
+
};
|
|
90
|
+
blobs = {
|
|
91
|
+
put: (bytes, mediaType) => this.#operation(() => {
|
|
92
|
+
if (!(bytes instanceof Uint8Array))
|
|
93
|
+
return refuse("bytes: must be a Uint8Array");
|
|
94
|
+
if (!isMediaType(mediaType))
|
|
95
|
+
return refuse("mediaType: must be a valid media type, such as application/pdf");
|
|
96
|
+
const snapshot = Buffer.from(bytes);
|
|
97
|
+
return this.#withWriteLock(() => writeBlob(this.#blobFolder, snapshot, mediaType));
|
|
98
|
+
}),
|
|
99
|
+
putStream: (chunks, mediaType) => this.#operation(() => {
|
|
100
|
+
if (typeof chunks?.[Symbol.asyncIterator] !== "function")
|
|
101
|
+
return refuse("chunks: must be an AsyncIterable of Uint8Array chunks");
|
|
102
|
+
if (!isMediaType(mediaType))
|
|
103
|
+
return refuse("mediaType: must be a valid media type, such as application/pdf");
|
|
104
|
+
return this.#withWriteLock(() => writeBlobStream(this.#blobFolder, snapshotBlobChunks(chunks), mediaType));
|
|
105
|
+
}),
|
|
106
|
+
get: (uri) => this.#operation(() => this.#getBlob(uri)),
|
|
107
|
+
stat: (uri) => this.#operation(() => this.#statBlob(uri)),
|
|
108
|
+
stream: (uri, range) => this.#operation(() => this.#streamBlob(uri, range)),
|
|
109
|
+
};
|
|
110
|
+
/** Checks a schema without accessing storage or checking link targets. */
|
|
111
|
+
static async prepareSchema(schema) {
|
|
112
|
+
return prepareDeclarationSchema(snapshotSchema(schema));
|
|
113
|
+
}
|
|
114
|
+
/** Subscribes after the shared watcher is ready; the returned function unsubscribes. */
|
|
115
|
+
async watch(onChange, options = {}) {
|
|
116
|
+
await this.#operation(() => this.#ensureReady());
|
|
117
|
+
// Each call is a subscription, even if callers reuse the same function.
|
|
118
|
+
const listener = { onChange, ...options };
|
|
119
|
+
this.#listeners.add(listener);
|
|
120
|
+
this.#watcher ??= this.#startWatcher();
|
|
121
|
+
try {
|
|
122
|
+
await this.#watcher;
|
|
123
|
+
this.#assertOpen();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.#listeners.delete(listener);
|
|
127
|
+
this.#watcher = undefined;
|
|
128
|
+
if (hasErrorCode(error))
|
|
129
|
+
throw new Error("Change monitoring failed");
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return async () => { this.#listeners.delete(listener); };
|
|
133
|
+
}
|
|
134
|
+
/** Stops change subscriptions. Future operations on this instance are refused. */
|
|
135
|
+
close() {
|
|
136
|
+
this.#closed = true;
|
|
137
|
+
this.#listeners.clear();
|
|
138
|
+
this.#closing ??= (async () => {
|
|
139
|
+
const watcher = await this.#watcher;
|
|
140
|
+
await this.#operation(async () => watcher?.close());
|
|
141
|
+
})();
|
|
142
|
+
return this.#closing;
|
|
143
|
+
}
|
|
144
|
+
async #operation(run) {
|
|
145
|
+
try {
|
|
146
|
+
return await run();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (hasErrorCode(error))
|
|
150
|
+
throw new Error("Storage operation failed");
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
#assertOpen() {
|
|
155
|
+
if (this.#closed)
|
|
156
|
+
refuse("Autofile is closed");
|
|
157
|
+
}
|
|
158
|
+
async #ensureReady() {
|
|
159
|
+
this.#assertOpen();
|
|
160
|
+
this.#ready ??= Promise.all([
|
|
161
|
+
mkdir(this.#dataFolder, { recursive: true }),
|
|
162
|
+
mkdir(dirname(this.#writeLockPath), { recursive: true }),
|
|
163
|
+
]).then(() => undefined);
|
|
164
|
+
await this.#ready;
|
|
165
|
+
this.#assertOpen();
|
|
166
|
+
}
|
|
167
|
+
#reportWatchError() {
|
|
168
|
+
const failure = new Error("Change monitoring failed");
|
|
169
|
+
for (const listener of this.#listeners)
|
|
170
|
+
reportSubscriptionError(listener, failure);
|
|
171
|
+
}
|
|
172
|
+
async #startWatcher() {
|
|
173
|
+
const watcher = await watchFolder(this.#dataFolder, (changes) => {
|
|
174
|
+
// Delivery is ordered independently of the filesystem watcher so a
|
|
175
|
+
// subscriber may await close without waiting for its own callback.
|
|
176
|
+
this.#notifications = this.#notifications.then(async () => {
|
|
177
|
+
await this.#watcher;
|
|
178
|
+
if (this.#closed)
|
|
179
|
+
return;
|
|
180
|
+
const events = await this.#logicalChanges(changes);
|
|
181
|
+
if (events.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
for (const subscription of [...this.#listeners]) {
|
|
184
|
+
if (!this.#listeners.has(subscription))
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
await subscription.onChange(events);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
reportSubscriptionError(subscription, toError(error));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}).catch(() => this.#reportWatchError());
|
|
194
|
+
}, () => this.#reportWatchError());
|
|
195
|
+
try {
|
|
196
|
+
for (const declaration of await this.#listTypes())
|
|
197
|
+
this.#watchTypes.add(declaration.type);
|
|
198
|
+
return watcher;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
await watcher.close();
|
|
202
|
+
throw new Error("Change monitoring failed");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async #logicalChanges(changes) {
|
|
206
|
+
const previousTypes = new Set(this.#watchTypes);
|
|
207
|
+
const uncertainTypes = new Set();
|
|
208
|
+
const events = [];
|
|
209
|
+
for (const change of changes) {
|
|
210
|
+
if (!change.uri.startsWith("autofile:///types/"))
|
|
211
|
+
continue;
|
|
212
|
+
const type = change.uri.slice("autofile:///types/".length);
|
|
213
|
+
try {
|
|
214
|
+
const declaration = await readValidDeclaration(this.#typeFolder, type);
|
|
215
|
+
if (declaration === undefined)
|
|
216
|
+
this.#watchTypes.delete(type);
|
|
217
|
+
else
|
|
218
|
+
this.#watchTypes.add(type);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// An unavailable declaration does not prove a type disappeared. Retain
|
|
222
|
+
// known membership and forward this batch's affected hints for rereading.
|
|
223
|
+
uncertainTypes.add(type);
|
|
224
|
+
this.#reportWatchError();
|
|
225
|
+
}
|
|
226
|
+
if (previousTypes.has(type) || this.#watchTypes.has(type) || uncertainTypes.has(type))
|
|
227
|
+
events.push(change);
|
|
228
|
+
}
|
|
229
|
+
for (const change of changes) {
|
|
230
|
+
const record = parseRecordUri(change.uri);
|
|
231
|
+
if (record !== undefined && (previousTypes.has(record.type) || this.#watchTypes.has(record.type) || uncertainTypes.has(record.type))) {
|
|
232
|
+
events.push(change);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return events;
|
|
236
|
+
}
|
|
237
|
+
async #withWriteLock(write) {
|
|
238
|
+
await this.#ensureReady();
|
|
43
239
|
let release;
|
|
44
240
|
let retryDelay = 50;
|
|
45
241
|
while (true) {
|
|
46
242
|
try {
|
|
47
|
-
release = await lockfile.lock(dirname(writeLockPath), {
|
|
48
|
-
lockfilePath: writeLockPath,
|
|
243
|
+
release = await lockfile.lock(dirname(this.#writeLockPath), {
|
|
244
|
+
lockfilePath: this.#writeLockPath,
|
|
49
245
|
stale: 10_000,
|
|
50
246
|
update: 5_000,
|
|
51
247
|
retries: 0,
|
|
@@ -66,9 +262,16 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
66
262
|
await release();
|
|
67
263
|
}
|
|
68
264
|
}
|
|
69
|
-
async
|
|
70
|
-
|
|
71
|
-
|
|
265
|
+
async #findRecords(input) {
|
|
266
|
+
await this.#ensureReady();
|
|
267
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
268
|
+
return refuse("query: must be an object");
|
|
269
|
+
const requestedTypes = input.type === undefined ? undefined
|
|
270
|
+
: typeof input.type === "string" ? [input.type] : input.type;
|
|
271
|
+
if (requestedTypes !== undefined && (!Array.isArray(requestedTypes)
|
|
272
|
+
|| requestedTypes.some((type) => typeof type !== "string" || !typeNamePattern.test(type)))) {
|
|
273
|
+
refuse(invalidTypeMessage);
|
|
274
|
+
}
|
|
72
275
|
if (input.q !== undefined && typeof input.q !== "string") {
|
|
73
276
|
return refuse("q: must be a string");
|
|
74
277
|
}
|
|
@@ -93,22 +296,27 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
93
296
|
return refuse(whereRefusal);
|
|
94
297
|
const afterUri = input.cursor === undefined
|
|
95
298
|
? undefined
|
|
96
|
-
: decodeRecordCursor(input.cursor,
|
|
299
|
+
: decodeRecordCursor(input.cursor, requestedTypes);
|
|
97
300
|
if (input.cursor !== undefined && afterUri === undefined) {
|
|
98
301
|
return refuse("cursor: is invalid");
|
|
99
302
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
303
|
+
const declarations = requestedTypes === undefined
|
|
304
|
+
? await this.#listTypes()
|
|
305
|
+
: await Promise.all([...new Set(requestedTypes)].map(async (type) => {
|
|
306
|
+
const declaration = await this.#getType(type);
|
|
307
|
+
if (declaration === undefined)
|
|
308
|
+
refuse("type does not exist", typeUri(type));
|
|
309
|
+
return declaration;
|
|
310
|
+
}));
|
|
311
|
+
const entries = [];
|
|
312
|
+
for (const declaration of declarations) {
|
|
313
|
+
const schema = await preparedDeclarationSchema(declaration, declaration.uri);
|
|
314
|
+
for (const entry of await recognizedRecordEntries(recordFolder(this.#dataFolder, declaration.type))) {
|
|
315
|
+
const key = entry.name.slice(0, -".json".length);
|
|
316
|
+
entries.push({ type: declaration.type, key, name: entry.name, uri: recordUri(declaration.type, key), declaration, schema });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
entries.sort((left, right) => left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0);
|
|
112
320
|
const start = afterUri === undefined
|
|
113
321
|
? 0
|
|
114
322
|
: entries.findIndex(({ uri }) => uri > afterUri);
|
|
@@ -125,15 +333,15 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
125
333
|
if (current === undefined)
|
|
126
334
|
break;
|
|
127
335
|
nextIndex += 1;
|
|
128
|
-
const {
|
|
336
|
+
const { type, key, name, uri, declaration, schema } = current;
|
|
129
337
|
let stored;
|
|
130
338
|
let record;
|
|
131
339
|
let resultError;
|
|
132
340
|
try {
|
|
133
|
-
stored = await readStoredRecord(join(dataFolder,
|
|
341
|
+
stored = await readStoredRecord(join(recordFolder(this.#dataFolder, type), name));
|
|
134
342
|
}
|
|
135
343
|
catch (error) {
|
|
136
|
-
resultError = { uri, message:
|
|
344
|
+
resultError = { uri, message: recordReadError(error) };
|
|
137
345
|
}
|
|
138
346
|
if (stored !== undefined) {
|
|
139
347
|
if (stored.status !== "found") {
|
|
@@ -149,14 +357,10 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
149
357
|
resultError = { uri, message: validationErrors.join("; ") };
|
|
150
358
|
}
|
|
151
359
|
else {
|
|
152
|
-
record =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
value: fields === undefined
|
|
157
|
-
? stored.value.value
|
|
158
|
-
: Object.fromEntries(Object.entries(stored.value.value).filter(([field]) => fields.has(field))),
|
|
159
|
-
};
|
|
360
|
+
record = recordResult(type, key, declaration, stored.value);
|
|
361
|
+
if (fields !== undefined) {
|
|
362
|
+
record.value = Object.fromEntries(Object.entries(record.value).filter(([field]) => fields.has(field)));
|
|
363
|
+
}
|
|
160
364
|
}
|
|
161
365
|
}
|
|
162
366
|
}
|
|
@@ -168,7 +372,7 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
168
372
|
const nextErrors = resultError === undefined ? errors : [...errors, resultError];
|
|
169
373
|
const nextPageBytes = Buffer.byteLength(JSON.stringify({
|
|
170
374
|
records: nextRecords,
|
|
171
|
-
|
|
375
|
+
unreadable: nextErrors,
|
|
172
376
|
}), "utf8");
|
|
173
377
|
if (records.length + errors.length > 0 && nextPageBytes > MAX_FIND_RECORDS_PAGE_BYTES) {
|
|
174
378
|
// Reading the candidate is necessary to know its serialized size, but it belongs to the
|
|
@@ -187,182 +391,166 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
187
391
|
}
|
|
188
392
|
return {
|
|
189
393
|
records,
|
|
190
|
-
errors,
|
|
394
|
+
unreadable: errors,
|
|
191
395
|
...(lastReadUri !== undefined && nextIndex < entries.length
|
|
192
396
|
? { cursor: encodeRecordCursor(lastReadUri) }
|
|
193
397
|
: {}),
|
|
194
398
|
};
|
|
195
399
|
}
|
|
196
|
-
async
|
|
197
|
-
|
|
400
|
+
async #getRecord(uri) {
|
|
401
|
+
await this.#ensureReady();
|
|
402
|
+
const parsed = parseRecordUri(uri);
|
|
198
403
|
if (parsed === undefined)
|
|
199
404
|
return refuse(invalidRecordUriMessage);
|
|
200
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
405
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
406
|
+
if (declaration.status === "missing")
|
|
407
|
+
return undefined;
|
|
201
408
|
if (declaration.status !== "found")
|
|
202
409
|
return declarationRefusal(parsed.type, declaration);
|
|
203
|
-
const stored = await readStoredRecord(recordPath(dataFolder, parsed.type, parsed.key));
|
|
410
|
+
const stored = await readStoredRecord(recordPath(this.#dataFolder, parsed.type, parsed.key));
|
|
411
|
+
if (stored.status === "missing")
|
|
412
|
+
return undefined;
|
|
204
413
|
if (stored.status !== "found")
|
|
205
|
-
return recordReadRefusal(
|
|
414
|
+
return recordReadRefusal(uri, stored);
|
|
206
415
|
const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
207
|
-
if ("errors" in schema)
|
|
208
|
-
return schema;
|
|
209
416
|
const validationErrors = await schema.validate(stored.value.value);
|
|
210
|
-
const result =
|
|
211
|
-
uri: input.uri,
|
|
212
|
-
createdAt: stored.value.createdAt,
|
|
213
|
-
updatedAt: stored.value.updatedAt,
|
|
214
|
-
value: stored.value.value,
|
|
215
|
-
};
|
|
417
|
+
const result = recordResult(parsed.type, parsed.key, declaration.value, stored.value);
|
|
216
418
|
if (validationErrors.length > 0)
|
|
217
|
-
result.
|
|
419
|
+
result.problems = validationErrors.map((message) => ({ uri, message }));
|
|
218
420
|
return result;
|
|
219
421
|
}
|
|
220
|
-
async
|
|
221
|
-
return withWriteLock(async () => {
|
|
222
|
-
if (!typeNamePattern.test(
|
|
422
|
+
async #createRecord(type, value, options) {
|
|
423
|
+
return this.#withWriteLock(async () => {
|
|
424
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
223
425
|
return refuse(invalidTypeMessage);
|
|
224
|
-
if (
|
|
426
|
+
if (options.key !== undefined && (typeof options.key !== "string" || !keyPattern.test(options.key))) {
|
|
225
427
|
return refuse(invalidKeyMessage);
|
|
226
428
|
}
|
|
227
|
-
if (!isJsonObject(
|
|
429
|
+
if (!isJsonObject(value))
|
|
228
430
|
return refuse("record: must be a JSON object");
|
|
229
|
-
const declaration = await readDeclaration(typeFolder,
|
|
431
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
230
432
|
if (declaration.status !== "found")
|
|
231
|
-
return declarationRefusal(
|
|
232
|
-
const schema = await preparedDeclarationSchema(declaration.value, typeUri(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const validationErrors = await schema.validate(input.record);
|
|
433
|
+
return declarationRefusal(type, declaration);
|
|
434
|
+
const schema = await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
435
|
+
const sourceUri = options.key === undefined ? undefined : recordUri(type, options.key);
|
|
436
|
+
const validationErrors = await schema.validate(value);
|
|
236
437
|
if (validationErrors.length > 0) {
|
|
237
|
-
|
|
238
|
-
return { errors: validationErrors.map((message) => ({ ...(uri === undefined ? {} : { uri }), message })) };
|
|
438
|
+
throw new AutofileError(validationErrors.map((message) => ({ ...(sourceUri === undefined ? {} : { uri: sourceUri }), message })));
|
|
239
439
|
}
|
|
240
|
-
const
|
|
241
|
-
const linkErrors = await missingLinkErrors(dataFolder, typeFolder, schema, input.record, sourceUri);
|
|
440
|
+
const linkErrors = await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, sourceUri);
|
|
242
441
|
if (linkErrors.length > 0)
|
|
243
|
-
|
|
244
|
-
await mkdir(
|
|
245
|
-
let key =
|
|
442
|
+
throw new AutofileError(linkErrors);
|
|
443
|
+
await mkdir(recordFolder(this.#dataFolder, type), { recursive: true });
|
|
444
|
+
let key = options.key;
|
|
246
445
|
if (key === undefined) {
|
|
247
446
|
do
|
|
248
447
|
key = nextTid();
|
|
249
|
-
while (await pathExists(recordPath(dataFolder,
|
|
448
|
+
while (await pathExists(recordPath(this.#dataFolder, type, key)));
|
|
250
449
|
}
|
|
251
|
-
else if (await pathExists(recordPath(dataFolder,
|
|
252
|
-
return refuse("a record with that key already exists", recordUri(
|
|
450
|
+
else if (await pathExists(recordPath(this.#dataFolder, type, key))) {
|
|
451
|
+
return refuse("a record with that key already exists", recordUri(type, key));
|
|
253
452
|
}
|
|
254
|
-
const uri = recordUri(
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
const materializedLinkErrors = await missingLinkErrors(dataFolder, typeFolder, schema, materialized.value, uri);
|
|
259
|
-
if (materializedLinkErrors.length > 0)
|
|
260
|
-
return { errors: materializedLinkErrors };
|
|
453
|
+
const uri = recordUri(type, key);
|
|
454
|
+
const blobErrors = await missingBlobErrors(this.#blobFolder, schema, value, uri);
|
|
455
|
+
if (blobErrors.length > 0)
|
|
456
|
+
throw new AutofileError(blobErrors);
|
|
261
457
|
const now = new Date().toISOString();
|
|
262
|
-
|
|
263
|
-
await writeJsonAtomically(recordPath(dataFolder,
|
|
264
|
-
|
|
265
|
-
updatedAt: now,
|
|
266
|
-
value: materialized.value,
|
|
267
|
-
});
|
|
268
|
-
return { uri };
|
|
458
|
+
const stored = { createdAt: now, updatedAt: now, value };
|
|
459
|
+
await writeJsonAtomically(recordPath(this.#dataFolder, type, key), stored);
|
|
460
|
+
return recordResult(type, key, declaration.value, stored);
|
|
269
461
|
});
|
|
270
462
|
}
|
|
271
|
-
async
|
|
272
|
-
return withWriteLock(async () => {
|
|
273
|
-
const parsed = parseRecordUri(
|
|
463
|
+
async #updateRecord(uri, patch) {
|
|
464
|
+
return this.#withWriteLock(async () => {
|
|
465
|
+
const parsed = parseRecordUri(uri);
|
|
274
466
|
if (parsed === undefined)
|
|
275
467
|
return refuse(invalidRecordUriMessage);
|
|
276
|
-
if (!isJsonObject(
|
|
277
|
-
return refuse("patch: must be a JSON object",
|
|
278
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
468
|
+
if (!isJsonObject(patch))
|
|
469
|
+
return refuse("patch: must be a JSON object", uri);
|
|
470
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
279
471
|
if (declaration.status !== "found")
|
|
280
472
|
return declarationRefusal(parsed.type, declaration);
|
|
281
|
-
const path = recordPath(dataFolder, parsed.type, parsed.key);
|
|
473
|
+
const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
|
|
282
474
|
const stored = await readStoredRecord(path);
|
|
283
475
|
if (stored.status !== "found")
|
|
284
|
-
return recordReadRefusal(
|
|
285
|
-
const value = mergePatch(stored.value.value,
|
|
476
|
+
return recordReadRefusal(uri, stored);
|
|
477
|
+
const value = mergePatch(stored.value.value, patch);
|
|
478
|
+
const problem = jsonObjectProblem(value);
|
|
479
|
+
if (problem !== undefined)
|
|
480
|
+
return refuse(`record: ${problem}`, uri);
|
|
286
481
|
const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
287
|
-
if ("errors" in schema)
|
|
288
|
-
return schema;
|
|
289
482
|
const validationErrors = await schema.validate(value);
|
|
290
483
|
if (validationErrors.length > 0) {
|
|
291
|
-
|
|
484
|
+
throw new AutofileError(validationErrors.map((message) => ({ uri, message })));
|
|
292
485
|
}
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
const materializedLinkErrors = await missingLinkErrors(dataFolder, typeFolder, schema, materialized.value, input.uri);
|
|
300
|
-
if (materializedLinkErrors.length > 0)
|
|
301
|
-
return { errors: materializedLinkErrors };
|
|
486
|
+
const errors = [
|
|
487
|
+
...await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, uri),
|
|
488
|
+
...await missingBlobErrors(this.#blobFolder, schema, value, uri),
|
|
489
|
+
];
|
|
490
|
+
if (errors.length > 0)
|
|
491
|
+
throw new AutofileError(errors);
|
|
302
492
|
const previousBlobs = await blobsHeldByRecord(stored.value.value, schema);
|
|
303
|
-
|
|
304
|
-
await writeJsonAtomically(path, {
|
|
493
|
+
const updated = {
|
|
305
494
|
createdAt: stored.value.createdAt,
|
|
306
495
|
updatedAt: new Date().toISOString(),
|
|
307
|
-
value
|
|
308
|
-
}
|
|
309
|
-
await
|
|
310
|
-
|
|
496
|
+
value,
|
|
497
|
+
};
|
|
498
|
+
await writeJsonAtomically(path, updated);
|
|
499
|
+
await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
|
|
500
|
+
return recordResult(parsed.type, parsed.key, declaration.value, updated);
|
|
311
501
|
});
|
|
312
502
|
}
|
|
313
|
-
async
|
|
314
|
-
return withWriteLock(async () => {
|
|
315
|
-
const parsed = parseRecordUri(
|
|
503
|
+
async #deleteRecord(targetUri) {
|
|
504
|
+
return this.#withWriteLock(async () => {
|
|
505
|
+
const parsed = parseRecordUri(targetUri);
|
|
316
506
|
if (parsed === undefined)
|
|
317
507
|
return refuse(invalidRecordUriMessage);
|
|
318
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
508
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
319
509
|
if (declaration.status !== "found")
|
|
320
510
|
return declarationRefusal(parsed.type, declaration);
|
|
321
|
-
const path = recordPath(dataFolder, parsed.type, parsed.key);
|
|
511
|
+
const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
|
|
322
512
|
if (!await isRegularFile(path))
|
|
323
|
-
return refuse("record does not exist",
|
|
513
|
+
return refuse("record does not exist", targetUri);
|
|
324
514
|
const changes = [];
|
|
325
515
|
const errors = [];
|
|
326
516
|
let deletedRecord;
|
|
327
517
|
try {
|
|
328
518
|
const target = await readStoredRecord(path);
|
|
329
519
|
if (target.status === "missing") {
|
|
330
|
-
errors.push({ uri:
|
|
520
|
+
errors.push({ uri: targetUri, message: "record does not exist" });
|
|
331
521
|
}
|
|
332
522
|
else if (target.status === "invalid") {
|
|
333
|
-
errors.push({ uri:
|
|
523
|
+
errors.push({ uri: targetUri, message: target.message });
|
|
334
524
|
}
|
|
335
525
|
else {
|
|
336
526
|
deletedRecord = target.value;
|
|
337
527
|
}
|
|
338
528
|
}
|
|
339
529
|
catch (error) {
|
|
340
|
-
errors.push({ uri:
|
|
530
|
+
errors.push({ uri: targetUri, message: recordReadError(error) });
|
|
341
531
|
}
|
|
342
|
-
const typeEntries = (await readDirectory(typeFolder))
|
|
532
|
+
const typeEntries = (await readDirectory(this.#typeFolder))
|
|
343
533
|
.filter((entry) => (entry.isFile()
|
|
344
534
|
&& entry.name.endsWith(".json")
|
|
345
535
|
&& typeNamePattern.test(entry.name.slice(0, -".json".length))))
|
|
346
536
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
347
537
|
for (const typeEntry of typeEntries) {
|
|
348
538
|
const type = typeEntry.name.slice(0, -".json".length);
|
|
349
|
-
const otherDeclaration = await readValidDeclaration(typeFolder, type);
|
|
539
|
+
const otherDeclaration = await readValidDeclaration(this.#typeFolder, type);
|
|
350
540
|
if (otherDeclaration === undefined)
|
|
351
541
|
continue;
|
|
352
542
|
const schema = await preparedDeclarationSchema(otherDeclaration, typeUri(type));
|
|
353
|
-
|
|
354
|
-
continue;
|
|
355
|
-
for (const entry of await recognizedRecordEntries(join(dataFolder, type))) {
|
|
543
|
+
for (const entry of await recognizedRecordEntries(recordFolder(this.#dataFolder, type))) {
|
|
356
544
|
const key = entry.name.slice(0, -".json".length);
|
|
357
545
|
const uri = recordUri(type, key);
|
|
358
|
-
if (uri ===
|
|
546
|
+
if (uri === targetUri)
|
|
359
547
|
continue;
|
|
360
548
|
let stored;
|
|
361
549
|
try {
|
|
362
|
-
stored = await readStoredRecord(join(dataFolder, type, entry.name));
|
|
550
|
+
stored = await readStoredRecord(join(recordFolder(this.#dataFolder, type), entry.name));
|
|
363
551
|
}
|
|
364
552
|
catch (error) {
|
|
365
|
-
errors.push({ uri, message:
|
|
553
|
+
errors.push({ uri, message: recordReadError(error) });
|
|
366
554
|
continue;
|
|
367
555
|
}
|
|
368
556
|
if (stored.status !== "found") {
|
|
@@ -374,16 +562,16 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
374
562
|
}
|
|
375
563
|
const validationErrors = await schema.validate(stored.value.value);
|
|
376
564
|
if (validationErrors.length > 0) {
|
|
377
|
-
if (containsStringValue(stored.value.value,
|
|
565
|
+
if (containsStringValue(stored.value.value, targetUri)) {
|
|
378
566
|
errors.push({
|
|
379
567
|
uri,
|
|
380
|
-
message: `record does not match its type and holds ${
|
|
568
|
+
message: `record does not match its type and holds ${targetUri} as a string value`,
|
|
381
569
|
});
|
|
382
570
|
}
|
|
383
571
|
continue;
|
|
384
572
|
}
|
|
385
573
|
const pointers = schema.formatFields(stored.value.value, "uri")
|
|
386
|
-
.filter((field) => field.value ===
|
|
574
|
+
.filter((field) => field.value === targetUri)
|
|
387
575
|
.map(({ pointer }) => pointer);
|
|
388
576
|
if (pointers.length === 0)
|
|
389
577
|
continue;
|
|
@@ -394,7 +582,7 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
394
582
|
continue;
|
|
395
583
|
}
|
|
396
584
|
changes.push({
|
|
397
|
-
path: join(dataFolder, type, entry.name),
|
|
585
|
+
path: join(recordFolder(this.#dataFolder, type), entry.name),
|
|
398
586
|
record: {
|
|
399
587
|
createdAt: stored.value.createdAt,
|
|
400
588
|
updatedAt: timestampAfter(stored.value.updatedAt),
|
|
@@ -408,302 +596,245 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
408
596
|
const uriOrder = (left.uri ?? "").localeCompare(right.uri ?? "");
|
|
409
597
|
return uriOrder === 0 ? left.message.localeCompare(right.message) : uriOrder;
|
|
410
598
|
});
|
|
411
|
-
|
|
599
|
+
throw new AutofileError(errors);
|
|
600
|
+
}
|
|
601
|
+
let targetSchema;
|
|
602
|
+
try {
|
|
603
|
+
targetSchema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
if (!(error instanceof AutofileError))
|
|
607
|
+
throw error;
|
|
412
608
|
}
|
|
413
|
-
const targetSchema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
414
609
|
const previousBlobs = deletedRecord === undefined
|
|
415
610
|
? new Set()
|
|
416
|
-
:
|
|
611
|
+
: targetSchema === undefined
|
|
417
612
|
? blobUrisIn(deletedRecord.value)
|
|
418
613
|
: await blobsHeldByRecord(deletedRecord.value, targetSchema);
|
|
419
614
|
for (const change of changes)
|
|
420
615
|
await writeJsonAtomically(change.path, change.record);
|
|
421
616
|
await unlink(path);
|
|
422
|
-
await deleteUnheldBlobs(dataFolder, typeFolder, previousBlobs);
|
|
423
|
-
return { uri: input.uri };
|
|
617
|
+
await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
|
|
424
618
|
});
|
|
425
619
|
}
|
|
426
|
-
async
|
|
427
|
-
|
|
620
|
+
async #listTypes() {
|
|
621
|
+
await this.#ensureReady();
|
|
428
622
|
const types = [];
|
|
429
|
-
for (const entry of
|
|
623
|
+
for (const entry of await readDirectory(this.#typeFolder)) {
|
|
430
624
|
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
431
625
|
continue;
|
|
432
626
|
const type = entry.name.slice(0, -".json".length);
|
|
433
627
|
if (!typeNamePattern.test(type))
|
|
434
628
|
continue;
|
|
435
|
-
const declaration = await readValidDeclaration(typeFolder, type);
|
|
436
|
-
if (declaration
|
|
437
|
-
|
|
438
|
-
types.push({ uri: typeUri(type), type, description: declaration.description });
|
|
629
|
+
const declaration = await readValidDeclaration(this.#typeFolder, type);
|
|
630
|
+
if (declaration !== undefined)
|
|
631
|
+
types.push(typeResult(type, declaration));
|
|
439
632
|
}
|
|
440
|
-
types.sort((left, right) => left.type.localeCompare(right.type));
|
|
441
|
-
|
|
633
|
+
return types.sort((left, right) => left.type.localeCompare(right.type));
|
|
634
|
+
}
|
|
635
|
+
async #getType(type) {
|
|
636
|
+
await this.#ensureReady();
|
|
637
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
638
|
+
refuse(invalidTypeMessage);
|
|
639
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
640
|
+
if (declaration.status === "missing")
|
|
641
|
+
return undefined;
|
|
642
|
+
if (declaration.status !== "found")
|
|
643
|
+
return declarationRefusal(type, declaration);
|
|
644
|
+
await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
645
|
+
return typeResult(type, declaration.value);
|
|
442
646
|
}
|
|
443
|
-
async
|
|
444
|
-
if (!typeNamePattern.test(
|
|
647
|
+
async #createType(type, definition) {
|
|
648
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
445
649
|
return refuse(invalidTypeMessage);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
if ("
|
|
451
|
-
return
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
650
|
+
if (!isJsonObject(definition))
|
|
651
|
+
return refuse("declaration: must be an object");
|
|
652
|
+
const { description, titleField } = definition;
|
|
653
|
+
const title = readTypeTitle(definition);
|
|
654
|
+
if (typeof description !== "string")
|
|
655
|
+
return refuse("description: must be a string");
|
|
656
|
+
if (titleField !== undefined && typeof titleField !== "string") {
|
|
657
|
+
return refuse("titleField: must be a string");
|
|
658
|
+
}
|
|
659
|
+
if (title !== undefined && (typeof title !== "string" || title.length === 0)) {
|
|
660
|
+
return refuse("title: must be a non-empty string");
|
|
661
|
+
}
|
|
662
|
+
// Validate and copy caller-owned data before acquiring the asynchronous lock.
|
|
663
|
+
const declaration = {
|
|
664
|
+
description,
|
|
665
|
+
...(title === undefined ? {} : { title }),
|
|
666
|
+
...(titleField === undefined ? {} : { titleField }),
|
|
667
|
+
schema: snapshotSchema(definition.schema),
|
|
459
668
|
};
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
return withWriteLock(async () => {
|
|
463
|
-
if (!typeNamePattern.test(input.type))
|
|
464
|
-
return refuse(invalidTypeMessage);
|
|
465
|
-
if (typeof input.description !== "string")
|
|
466
|
-
return refuse("description: must be a string");
|
|
467
|
-
if (input.titleField !== undefined && typeof input.titleField !== "string") {
|
|
468
|
-
return refuse("titleField: must be a string");
|
|
469
|
-
}
|
|
470
|
-
if (!isJsonObject(input.schema))
|
|
471
|
-
return refuse("schema: must be a JSON object");
|
|
472
|
-
const declarationPath = join(typeFolder, `${input.type}.json`);
|
|
669
|
+
return this.#withWriteLock(async () => {
|
|
670
|
+
const declarationPath = join(this.#typeFolder, `${type}.json`);
|
|
473
671
|
if (await pathExists(declarationPath)) {
|
|
474
|
-
return refuse("type already exists", typeUri(
|
|
672
|
+
return refuse("type already exists", typeUri(type));
|
|
475
673
|
}
|
|
476
|
-
|
|
477
|
-
if ("errors" in schema)
|
|
478
|
-
return schema;
|
|
674
|
+
await prepareDeclarationSchema(declaration.schema, typeUri(type));
|
|
479
675
|
await Promise.all([
|
|
480
|
-
mkdir(typeFolder, { recursive: true }),
|
|
481
|
-
mkdir(
|
|
676
|
+
mkdir(this.#typeFolder, { recursive: true }),
|
|
677
|
+
mkdir(recordFolder(this.#dataFolder, type), { recursive: true }),
|
|
482
678
|
]);
|
|
483
|
-
await writeJsonAtomically(declarationPath,
|
|
484
|
-
|
|
485
|
-
...(input.titleField === undefined ? {} : { titleField: input.titleField }),
|
|
486
|
-
schema: input.schema,
|
|
487
|
-
});
|
|
488
|
-
return { uri: typeUri(input.type) };
|
|
679
|
+
await writeJsonAtomically(declarationPath, declaration);
|
|
680
|
+
return typeResult(type, declaration);
|
|
489
681
|
});
|
|
490
682
|
}
|
|
491
|
-
async
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
683
|
+
async #updateType(type, changes) {
|
|
684
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
685
|
+
return refuse(invalidTypeMessage);
|
|
686
|
+
if (!isJsonObject(changes))
|
|
687
|
+
return refuse("changes: must be an object");
|
|
688
|
+
const { description, titleField: changedTitleField, schema: changedSchema } = changes;
|
|
689
|
+
const changedTitle = readTypeTitle(changes);
|
|
690
|
+
if (description !== undefined && typeof description !== "string") {
|
|
691
|
+
return refuse("description: must be a string");
|
|
692
|
+
}
|
|
693
|
+
if (changedTitleField !== undefined
|
|
694
|
+
&& changedTitleField !== null
|
|
695
|
+
&& typeof changedTitleField !== "string") {
|
|
696
|
+
return refuse("titleField: must be a string or null");
|
|
697
|
+
}
|
|
698
|
+
if (changedTitle !== undefined
|
|
699
|
+
&& changedTitle !== null
|
|
700
|
+
&& (typeof changedTitle !== "string" || changedTitle.length === 0)) {
|
|
701
|
+
return refuse("title: must be a non-empty string or null");
|
|
702
|
+
}
|
|
703
|
+
const schemaSnapshot = changedSchema === undefined ? undefined : snapshotSchema(changedSchema);
|
|
704
|
+
return this.#withWriteLock(async () => {
|
|
705
|
+
const current = await readDeclaration(this.#typeFolder, type);
|
|
496
706
|
if (current.status !== "found")
|
|
497
|
-
return declarationRefusal(
|
|
498
|
-
|
|
499
|
-
return refuse("description: must be a string");
|
|
500
|
-
}
|
|
501
|
-
if (input.titleField !== undefined
|
|
502
|
-
&& input.titleField !== null
|
|
503
|
-
&& typeof input.titleField !== "string") {
|
|
504
|
-
return refuse("titleField: must be a string or null");
|
|
505
|
-
}
|
|
506
|
-
if (input.schema !== undefined && !isJsonObject(input.schema)) {
|
|
507
|
-
return refuse("schema: must be a JSON object");
|
|
508
|
-
}
|
|
509
|
-
const titleField = input.titleField === null
|
|
707
|
+
return declarationRefusal(type, current);
|
|
708
|
+
const titleField = changedTitleField === null
|
|
510
709
|
? undefined
|
|
511
|
-
:
|
|
710
|
+
: changedTitleField ?? current.value.titleField;
|
|
711
|
+
const title = changedTitle === null ? undefined : changedTitle ?? current.value.title;
|
|
512
712
|
const declaration = {
|
|
513
|
-
description:
|
|
713
|
+
description: description ?? current.value.description,
|
|
714
|
+
...(title === undefined ? {} : { title }),
|
|
514
715
|
...(titleField === undefined ? {} : { titleField }),
|
|
515
|
-
schema:
|
|
716
|
+
schema: schemaSnapshot ?? snapshotSchema(current.value.schema),
|
|
516
717
|
};
|
|
517
|
-
const schema = await prepareDeclarationSchema(declaration.schema, typeUri(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
718
|
+
const schema = await prepareDeclarationSchema(declaration.schema, typeUri(type));
|
|
719
|
+
await writeJsonAtomically(join(this.#typeFolder, `${type}.json`), declaration);
|
|
720
|
+
const errors = await invalidRecords(this.#dataFolder, type, schema);
|
|
721
|
+
const result = typeResult(type, declaration);
|
|
722
|
+
if (errors.length > 0)
|
|
723
|
+
result.problems = errors;
|
|
724
|
+
return result;
|
|
523
725
|
});
|
|
524
726
|
}
|
|
525
|
-
async
|
|
526
|
-
return withWriteLock(async () => {
|
|
527
|
-
if (!typeNamePattern.test(
|
|
727
|
+
async #deleteType(type) {
|
|
728
|
+
return this.#withWriteLock(async () => {
|
|
729
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
528
730
|
return refuse(invalidTypeMessage);
|
|
529
|
-
const declaration = await readDeclaration(typeFolder,
|
|
731
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
530
732
|
if (declaration.status !== "found")
|
|
531
|
-
return declarationRefusal(
|
|
733
|
+
return declarationRefusal(type, declaration);
|
|
532
734
|
try {
|
|
533
|
-
await rmdir(
|
|
735
|
+
await rmdir(recordFolder(this.#dataFolder, type));
|
|
534
736
|
}
|
|
535
737
|
catch (error) {
|
|
536
|
-
|
|
537
|
-
|
|
738
|
+
// Keep the declaration whenever any stored data remains, including
|
|
739
|
+
// entries that cannot be recognized or read as records.
|
|
740
|
+
if (["ENOTDIR", "ENOTEMPTY", "EEXIST"].some((code) => hasCode(error, code))) {
|
|
741
|
+
return refuse("type still has stored data", typeUri(type));
|
|
538
742
|
}
|
|
539
743
|
if (!hasCode(error, "ENOENT"))
|
|
540
744
|
throw error;
|
|
541
745
|
}
|
|
542
|
-
await unlink(join(typeFolder, `${
|
|
543
|
-
return { uri: typeUri(input.type) };
|
|
746
|
+
await unlink(join(this.#typeFolder, `${type}.json`));
|
|
544
747
|
});
|
|
545
748
|
}
|
|
546
|
-
async
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
551
|
-
continue;
|
|
552
|
-
resources.push(...await listTypeResources(entry.name.slice(0, -".json".length)));
|
|
553
|
-
}
|
|
554
|
-
resources.push(...await listResourceFolder("_blobs"));
|
|
555
|
-
resources.sort((left, right) => left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0);
|
|
556
|
-
return resources;
|
|
557
|
-
}
|
|
558
|
-
async function getResourceMetadata(uri) {
|
|
559
|
-
const typeMatch = typeUriPattern.exec(uri);
|
|
560
|
-
const type = typeMatch?.[1];
|
|
561
|
-
if (type !== undefined) {
|
|
562
|
-
const declaration = await readValidDeclaration(typeFolder, type);
|
|
563
|
-
return declaration === undefined ? undefined : declarationResource(type, declaration);
|
|
564
|
-
}
|
|
565
|
-
const blobName = BLOB_URI_PATTERN.exec(uri)?.[1];
|
|
566
|
-
if (blobName !== undefined) {
|
|
567
|
-
const size = await regularFileSize(join(blobFolder, blobName), MAX_BLOB_BYTES);
|
|
568
|
-
return size === undefined ? undefined : {
|
|
569
|
-
uri,
|
|
570
|
-
name: `_blobs/${blobName}`,
|
|
571
|
-
mimeType: mimeTypeForBlob(blobName),
|
|
572
|
-
size,
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
const record = parseRecordUri(uri);
|
|
576
|
-
if (record === undefined)
|
|
577
|
-
return undefined;
|
|
578
|
-
const [declaration, stored] = await Promise.all([
|
|
579
|
-
readValidDeclaration(typeFolder, record.type),
|
|
580
|
-
readStoredRecord(recordPath(dataFolder, record.type, record.key)),
|
|
581
|
-
]);
|
|
582
|
-
if (declaration === undefined || stored.status !== "found")
|
|
749
|
+
async #getBlob(uri) {
|
|
750
|
+
await this.#ensureReady();
|
|
751
|
+
const name = blobNameFromUri(uri);
|
|
752
|
+
if (name === undefined || !await isDirectory(this.#blobFolder))
|
|
583
753
|
return undefined;
|
|
584
|
-
|
|
754
|
+
const bytes = await readRegularFile(join(this.#blobFolder, name));
|
|
755
|
+
return bytes === undefined ? undefined : { bytes, mimeType: mimeTypeForBlob(name) };
|
|
585
756
|
}
|
|
586
|
-
async
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const key = entry.name.slice(0, -".json".length);
|
|
596
|
-
const stored = await readStoredRecord(join(dataFolder, type, entry.name));
|
|
597
|
-
if (stored.status !== "found")
|
|
598
|
-
continue;
|
|
599
|
-
resources.push(recordResource(type, key, declaration, stored.value));
|
|
600
|
-
}
|
|
601
|
-
return resources;
|
|
602
|
-
}
|
|
603
|
-
async function listResourceFolder(folder) {
|
|
604
|
-
if (folder === "_types") {
|
|
605
|
-
const declarationEntries = await readDirectory(typeFolder);
|
|
606
|
-
const resources = [];
|
|
607
|
-
for (const entry of declarationEntries) {
|
|
608
|
-
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
609
|
-
continue;
|
|
610
|
-
const type = entry.name.slice(0, -".json".length);
|
|
611
|
-
const resource = await getResourceMetadata(typeUri(type));
|
|
612
|
-
if (resource !== undefined)
|
|
613
|
-
resources.push(resource);
|
|
614
|
-
}
|
|
615
|
-
return resources;
|
|
616
|
-
}
|
|
617
|
-
if (folder === "_blobs") {
|
|
618
|
-
const resources = [];
|
|
619
|
-
for (const entry of await readDirectory(blobFolder)) {
|
|
620
|
-
if (!entry.isFile() || !BLOB_NAME_PATTERN.test(entry.name))
|
|
621
|
-
continue;
|
|
622
|
-
const resource = await getResourceMetadata(blobUri(entry.name));
|
|
623
|
-
if (resource !== undefined)
|
|
624
|
-
resources.push(resource);
|
|
625
|
-
}
|
|
626
|
-
return resources;
|
|
627
|
-
}
|
|
628
|
-
if (!typeNamePattern.test(folder))
|
|
629
|
-
return [];
|
|
630
|
-
const recordPrefix = `autofile:///${folder}/`;
|
|
631
|
-
return (await listTypeResources(folder)).filter(({ uri }) => uri.startsWith(recordPrefix));
|
|
632
|
-
}
|
|
633
|
-
async function readResource(uri) {
|
|
634
|
-
const typeMatch = typeUriPattern.exec(uri);
|
|
635
|
-
const type = typeMatch?.[1];
|
|
636
|
-
if (type !== undefined) {
|
|
637
|
-
const declaration = await readValidDeclarationText(typeFolder, type);
|
|
638
|
-
if (declaration === undefined)
|
|
639
|
-
return undefined;
|
|
640
|
-
return { uri, mimeType: "application/json", text: declaration.text };
|
|
757
|
+
async #statBlob(uri) {
|
|
758
|
+
await this.#ensureReady();
|
|
759
|
+
const name = blobNameFromUri(uri);
|
|
760
|
+
if (name === undefined || !await isDirectory(this.#blobFolder))
|
|
761
|
+
return undefined;
|
|
762
|
+
const path = join(this.#blobFolder, name);
|
|
763
|
+
try {
|
|
764
|
+
const details = await lstat(path);
|
|
765
|
+
return details.isFile() ? { size: details.size, mimeType: mimeTypeForBlob(name), path } : undefined;
|
|
641
766
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (blobName !== undefined) {
|
|
645
|
-
if (!await isDirectory(blobFolder))
|
|
646
|
-
return undefined;
|
|
647
|
-
const bytes = await readRegularFile(join(blobFolder, blobName), MAX_BLOB_BYTES);
|
|
648
|
-
if (bytes === undefined)
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR"))
|
|
649
769
|
return undefined;
|
|
650
|
-
|
|
770
|
+
throw error;
|
|
651
771
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
772
|
+
}
|
|
773
|
+
async #streamBlob(uri, range) {
|
|
774
|
+
await this.#ensureReady();
|
|
775
|
+
const name = blobNameFromUri(uri);
|
|
776
|
+
if (name === undefined || !await isDirectory(this.#blobFolder))
|
|
656
777
|
return undefined;
|
|
657
|
-
|
|
658
|
-
|
|
778
|
+
// Read caller-controlled offsets before opening a file: an accessor may throw.
|
|
779
|
+
const start = range === undefined ? 0 : range?.start;
|
|
780
|
+
const requestedEnd = range?.end;
|
|
781
|
+
const file = await openRegularFile(join(this.#blobFolder, name));
|
|
782
|
+
if (file === undefined)
|
|
659
783
|
return undefined;
|
|
660
|
-
|
|
784
|
+
const end = range === undefined ? file.size : requestedEnd;
|
|
785
|
+
if (typeof start !== "number" || typeof end !== "number"
|
|
786
|
+
|| !Number.isSafeInteger(start) || !Number.isSafeInteger(end)
|
|
787
|
+
|| start < 0 || end < start || end > file.size) {
|
|
788
|
+
await file.handle.close();
|
|
789
|
+
return refuse("range: must contain integer offsets with 0 <= start <= end <= blob size", uri);
|
|
790
|
+
}
|
|
791
|
+
return streamFile(file.handle, start, end);
|
|
661
792
|
}
|
|
662
|
-
return {
|
|
663
|
-
findRecords,
|
|
664
|
-
getRecord,
|
|
665
|
-
createRecord,
|
|
666
|
-
updateRecord,
|
|
667
|
-
deleteRecord,
|
|
668
|
-
listTypes,
|
|
669
|
-
getType,
|
|
670
|
-
createType,
|
|
671
|
-
updateType,
|
|
672
|
-
deleteType,
|
|
673
|
-
listResources,
|
|
674
|
-
getResourceMetadata,
|
|
675
|
-
listResourceFolder,
|
|
676
|
-
listTypeResources,
|
|
677
|
-
readResource,
|
|
678
|
-
};
|
|
679
793
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
794
|
+
/** Copies one chunk before asynchronous writes can observe caller mutations. */
|
|
795
|
+
async function* snapshotBlobChunks(chunks) {
|
|
796
|
+
for await (const chunk of chunks) {
|
|
797
|
+
if (!(chunk instanceof Uint8Array))
|
|
798
|
+
refuse("chunks: each chunk must be a Uint8Array");
|
|
799
|
+
yield Buffer.from(chunk);
|
|
800
|
+
}
|
|
687
801
|
}
|
|
688
|
-
function
|
|
689
|
-
const title = declaration.titleField === undefined
|
|
690
|
-
? undefined
|
|
691
|
-
: stored.value[declaration.titleField];
|
|
802
|
+
function recordResult(type, key, declaration, stored) {
|
|
803
|
+
const title = declaration.titleField === undefined ? undefined : stored.value[declaration.titleField];
|
|
692
804
|
return {
|
|
693
|
-
uri: recordUri(type, key),
|
|
694
|
-
name: `${type}/${key}`,
|
|
805
|
+
uri: recordUri(type, key), type, key,
|
|
695
806
|
...(typeof title === "string" ? { title } : {}),
|
|
696
|
-
|
|
697
|
-
lastModified: stored.updatedAt,
|
|
807
|
+
...stored,
|
|
698
808
|
};
|
|
699
809
|
}
|
|
810
|
+
function typeResult(type, definition) {
|
|
811
|
+
return { uri: typeUri(type), type, ...definition };
|
|
812
|
+
}
|
|
813
|
+
function jsonText(value) { return `${JSON.stringify(value, null, 2)}\n`; }
|
|
814
|
+
function readTypeTitle(input) {
|
|
815
|
+
// Read the descriptor so an accessor cannot run caller code or supply a value
|
|
816
|
+
// that JSON serialization would omit. Validation and snapshotting stay synchronous.
|
|
817
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, "title");
|
|
818
|
+
if (descriptor === undefined)
|
|
819
|
+
return undefined;
|
|
820
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
821
|
+
refuse("title: must be an enumerable data property");
|
|
822
|
+
}
|
|
823
|
+
return descriptor.value;
|
|
824
|
+
}
|
|
825
|
+
function snapshotSchema(schema) {
|
|
826
|
+
const problem = jsonObjectProblem(schema);
|
|
827
|
+
if (problem !== undefined)
|
|
828
|
+
refuse(`schema: ${problem}`);
|
|
829
|
+
return JSON.parse(JSON.stringify(schema));
|
|
830
|
+
}
|
|
700
831
|
async function prepareDeclarationSchema(schema, uri) {
|
|
701
832
|
try {
|
|
702
833
|
return await prepareSchema(schema);
|
|
703
834
|
}
|
|
704
835
|
catch (error) {
|
|
705
836
|
if (error instanceof SchemaRefusal) {
|
|
706
|
-
|
|
837
|
+
throw new AutofileError(error.messages.map((message) => ({ ...(uri === undefined ? {} : { uri }), message })));
|
|
707
838
|
}
|
|
708
839
|
throw error;
|
|
709
840
|
}
|
|
@@ -736,57 +867,20 @@ async function missingLinkErrors(dataFolder, typeFolder, schema, value, sourceUr
|
|
|
736
867
|
}
|
|
737
868
|
return errors;
|
|
738
869
|
}
|
|
739
|
-
async function
|
|
740
|
-
const materialized = structuredClone(value);
|
|
741
|
-
const preparedBlobs = new Map();
|
|
870
|
+
async function missingBlobErrors(blobFolder, schema, value, uri) {
|
|
742
871
|
const errors = [];
|
|
743
|
-
const
|
|
872
|
+
const checked = new Map();
|
|
744
873
|
for (const field of schema.formatFields(value, "blob")) {
|
|
745
|
-
if (visitedPointers.has(field.pointer))
|
|
746
|
-
continue;
|
|
747
|
-
visitedPointers.add(field.pointer);
|
|
748
874
|
if (typeof field.value !== "string")
|
|
749
875
|
continue;
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
errors.push(formatFieldError(uri, field.location, prepared.message));
|
|
753
|
-
continue;
|
|
754
|
-
}
|
|
755
|
-
setValueAtPointer(materialized, field.pointer, prepared.uri);
|
|
756
|
-
preparedBlobs.set(prepared.uri, prepared);
|
|
757
|
-
}
|
|
758
|
-
if (errors.length > 0)
|
|
759
|
-
return { errors };
|
|
760
|
-
const validationErrors = await schema.validate(materialized);
|
|
761
|
-
if (validationErrors.length > 0) {
|
|
762
|
-
return { errors: validationErrors.map((message) => ({ uri, message })) };
|
|
763
|
-
}
|
|
764
|
-
const finalBlobs = new Map();
|
|
765
|
-
const finalPointers = new Set();
|
|
766
|
-
for (const field of schema.formatFields(materialized, "blob")) {
|
|
767
|
-
if (finalPointers.has(field.pointer))
|
|
768
|
-
continue;
|
|
769
|
-
finalPointers.add(field.pointer);
|
|
770
|
-
const fieldValue = typeof field.value === "string" ? field.value : undefined;
|
|
771
|
-
if (fieldValue === undefined || blobNameFromUri(fieldValue) === undefined) {
|
|
772
|
-
const message = "must hold a blob URI after storing";
|
|
773
|
-
errors.push(formatFieldError(uri, field.location, message));
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
let prepared = preparedBlobs.get(fieldValue);
|
|
777
|
-
if (prepared === undefined) {
|
|
778
|
-
const existing = await prepareBlob(fieldValue, blobFolder, false);
|
|
779
|
-
if ("message" in existing) {
|
|
780
|
-
errors.push(formatFieldError(uri, field.location, existing.message));
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
prepared = existing;
|
|
876
|
+
if (!checked.has(field.value)) {
|
|
877
|
+
checked.set(field.value, await blobReferenceProblem(blobFolder, field.value));
|
|
784
878
|
}
|
|
785
|
-
|
|
879
|
+
const problem = checked.get(field.value);
|
|
880
|
+
if (problem !== undefined)
|
|
881
|
+
errors.push(formatFieldError(uri, field.location, problem));
|
|
786
882
|
}
|
|
787
|
-
|
|
788
|
-
return { errors };
|
|
789
|
-
return { value: materialized, blobs: [...finalBlobs.values()] };
|
|
883
|
+
return errors;
|
|
790
884
|
}
|
|
791
885
|
function formatFieldError(uri, location, message) {
|
|
792
886
|
return { uri, message: location === "" ? message : `${location}: ${message}` };
|
|
@@ -805,7 +899,7 @@ async function deleteUnheldBlobs(dataFolder, typeFolder, candidates) {
|
|
|
805
899
|
const held = await blobsHeldInStore(dataFolder, typeFolder);
|
|
806
900
|
if (held === undefined)
|
|
807
901
|
return;
|
|
808
|
-
await deleteBlobUris(join(dataFolder, "
|
|
902
|
+
await deleteBlobUris(join(dataFolder, "blobs"), [...candidates].filter((uri) => !held.has(uri)));
|
|
809
903
|
}
|
|
810
904
|
async function blobsHeldInStore(dataFolder, typeFolder) {
|
|
811
905
|
const held = new Set();
|
|
@@ -833,13 +927,17 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
|
|
|
833
927
|
}
|
|
834
928
|
let schema;
|
|
835
929
|
if (declaration.status === "found") {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
930
|
+
try {
|
|
931
|
+
schema = await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
if (!(error instanceof AutofileError))
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
839
937
|
}
|
|
840
938
|
let recordEntries;
|
|
841
939
|
try {
|
|
842
|
-
recordEntries = await recognizedRecordEntries(
|
|
940
|
+
recordEntries = await recognizedRecordEntries(recordFolder(dataFolder, type));
|
|
843
941
|
}
|
|
844
942
|
catch (error) {
|
|
845
943
|
if (hasErrorCode(error))
|
|
@@ -849,7 +947,7 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
|
|
|
849
947
|
for (const entry of recordEntries) {
|
|
850
948
|
let record;
|
|
851
949
|
try {
|
|
852
|
-
record = await
|
|
950
|
+
record = await readJson(join(recordFolder(dataFolder, type), entry.name), "record");
|
|
853
951
|
}
|
|
854
952
|
catch (error) {
|
|
855
953
|
if (hasErrorCode(error))
|
|
@@ -891,33 +989,6 @@ function collectBlobUris(value, uris) {
|
|
|
891
989
|
collectBlobUris(item, uris);
|
|
892
990
|
}
|
|
893
991
|
}
|
|
894
|
-
function setValueAtPointer(value, pointer, replacement) {
|
|
895
|
-
const path = parseJsonPointer(pointer);
|
|
896
|
-
const field = path.at(-1);
|
|
897
|
-
if (field === undefined)
|
|
898
|
-
return;
|
|
899
|
-
let parent = value;
|
|
900
|
-
for (const part of path.slice(0, -1)) {
|
|
901
|
-
let next;
|
|
902
|
-
if (Array.isArray(parent))
|
|
903
|
-
next = parent[Number(part)];
|
|
904
|
-
else if (isJsonObject(parent))
|
|
905
|
-
next = parent[part];
|
|
906
|
-
if (next === undefined)
|
|
907
|
-
return;
|
|
908
|
-
parent = next;
|
|
909
|
-
}
|
|
910
|
-
if (Array.isArray(parent))
|
|
911
|
-
parent[Number(field)] = replacement;
|
|
912
|
-
else if (isJsonObject(parent)) {
|
|
913
|
-
Object.defineProperty(parent, field, {
|
|
914
|
-
value: replacement,
|
|
915
|
-
enumerable: true,
|
|
916
|
-
configurable: true,
|
|
917
|
-
writable: true,
|
|
918
|
-
});
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
992
|
async function recordExists(dataFolder, typeFolder, uri) {
|
|
922
993
|
const parsed = parseRecordUri(uri);
|
|
923
994
|
if (parsed === undefined)
|
|
@@ -928,10 +999,7 @@ async function recordExists(dataFolder, typeFolder, uri) {
|
|
|
928
999
|
return stored.status === "found";
|
|
929
1000
|
}
|
|
930
1001
|
async function readValidDeclaration(typeFolder, type) {
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
async function readValidDeclarationText(typeFolder, type) {
|
|
934
|
-
const result = await readDeclarationText(typeFolder, type);
|
|
1002
|
+
const result = await readDeclaration(typeFolder, type);
|
|
935
1003
|
if (result.status !== "found")
|
|
936
1004
|
return undefined;
|
|
937
1005
|
try {
|
|
@@ -942,15 +1010,22 @@ async function readValidDeclarationText(typeFolder, type) {
|
|
|
942
1010
|
return undefined;
|
|
943
1011
|
throw error;
|
|
944
1012
|
}
|
|
945
|
-
return
|
|
1013
|
+
return result.value;
|
|
946
1014
|
}
|
|
947
1015
|
async function invalidRecords(dataFolder, type, schema) {
|
|
948
1016
|
const errors = [];
|
|
949
|
-
const entries = await recognizedRecordEntries(
|
|
1017
|
+
const entries = await recognizedRecordEntries(recordFolder(dataFolder, type));
|
|
950
1018
|
for (const entry of entries) {
|
|
951
1019
|
const key = entry.name.slice(0, -".json".length);
|
|
952
1020
|
const uri = recordUri(type, key);
|
|
953
|
-
|
|
1021
|
+
let record;
|
|
1022
|
+
try {
|
|
1023
|
+
record = await readStoredRecord(join(recordFolder(dataFolder, type), entry.name));
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
errors.push({ uri, message: recordReadError(error) });
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
954
1029
|
if (record.status !== "found") {
|
|
955
1030
|
errors.push({ uri, message: record.status === "invalid" ? record.message : "record does not exist" });
|
|
956
1031
|
continue;
|
|
@@ -962,60 +1037,42 @@ async function invalidRecords(dataFolder, type, schema) {
|
|
|
962
1037
|
return errors;
|
|
963
1038
|
}
|
|
964
1039
|
async function readDeclaration(folder, type) {
|
|
965
|
-
const result = await readDeclarationText(folder, type);
|
|
966
|
-
if (result.status !== "found")
|
|
967
|
-
return result;
|
|
968
|
-
return { status: "found", value: result.value };
|
|
969
|
-
}
|
|
970
|
-
async function readDeclarationText(folder, type) {
|
|
971
1040
|
if (!await isDirectory(folder))
|
|
972
1041
|
return { status: "missing" };
|
|
973
|
-
const result = await
|
|
1042
|
+
const result = await readJson(join(folder, `${type}.json`), "type declaration");
|
|
974
1043
|
if (result.status !== "found")
|
|
975
1044
|
return result;
|
|
976
|
-
if (!
|
|
1045
|
+
if (!isTypeDefinition(result.value)) {
|
|
977
1046
|
return { status: "invalid", message: "type declaration is not valid" };
|
|
978
1047
|
}
|
|
979
|
-
return { status: "found", value: result.value, text: result.text };
|
|
980
|
-
}
|
|
981
|
-
async function readStoredRecord(path) {
|
|
982
|
-
const result = await readStoredRecordText(path);
|
|
983
|
-
if (result.status !== "found")
|
|
984
|
-
return result;
|
|
985
1048
|
return { status: "found", value: result.value };
|
|
986
1049
|
}
|
|
987
|
-
async function
|
|
988
|
-
if (!await isDirectory(dirname(path)))
|
|
1050
|
+
async function readStoredRecord(path) {
|
|
1051
|
+
if (!await isDirectory(dirname(dirname(path))) || !await isDirectory(dirname(path)))
|
|
989
1052
|
return { status: "missing" };
|
|
990
|
-
const result = await
|
|
1053
|
+
const result = await readJson(path, "record");
|
|
991
1054
|
if (result.status !== "found")
|
|
992
1055
|
return result;
|
|
993
1056
|
if (!isStoredRecord(result.value)) {
|
|
994
|
-
return { status: "invalid", message: "record
|
|
1057
|
+
return { status: "invalid", message: "record is not valid" };
|
|
995
1058
|
}
|
|
996
|
-
return { status: "found", value: result.value
|
|
1059
|
+
return { status: "found", value: result.value };
|
|
997
1060
|
}
|
|
998
|
-
async function
|
|
1061
|
+
async function readJson(path, label) {
|
|
999
1062
|
const bytes = await readRegularFile(path);
|
|
1000
1063
|
if (bytes === undefined)
|
|
1001
1064
|
return { status: "missing" };
|
|
1002
|
-
let text;
|
|
1003
1065
|
try {
|
|
1004
|
-
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1066
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1067
|
+
return { status: "found", value: JSON.parse(text) };
|
|
1005
1068
|
}
|
|
1006
1069
|
catch {
|
|
1007
|
-
return { status: "invalid", message: `${label} is
|
|
1008
|
-
}
|
|
1009
|
-
try {
|
|
1010
|
-
return { status: "found", value: JSON.parse(text), text };
|
|
1011
|
-
}
|
|
1012
|
-
catch {
|
|
1013
|
-
return { status: "invalid", message: `${label} cannot be read as JSON` };
|
|
1070
|
+
return { status: "invalid", message: `${label} is unavailable` };
|
|
1014
1071
|
}
|
|
1015
1072
|
}
|
|
1016
1073
|
async function writeJsonAtomically(path, value) {
|
|
1017
1074
|
const temporaryPath = join(dirname(path), `.${path.slice(path.lastIndexOf("/") + 1)}.${process.pid}.${randomUUID()}`);
|
|
1018
|
-
const text =
|
|
1075
|
+
const text = jsonText(value);
|
|
1019
1076
|
try {
|
|
1020
1077
|
await writeFile(temporaryPath, text, { encoding: "utf8", flag: "wx" });
|
|
1021
1078
|
await rename(temporaryPath, path);
|
|
@@ -1067,34 +1124,33 @@ function nextTid() {
|
|
|
1067
1124
|
}
|
|
1068
1125
|
return tid;
|
|
1069
1126
|
}
|
|
1070
|
-
/** Returns whether a string is the canonical URI of an Autofile resource. */
|
|
1071
|
-
export function isResourceUri(uri) {
|
|
1072
|
-
return parseRecordUri(uri) !== undefined
|
|
1073
|
-
|| typeUriPattern.test(uri)
|
|
1074
|
-
|| BLOB_URI_PATTERN.test(uri);
|
|
1075
|
-
}
|
|
1076
1127
|
function parseRecordUri(uri) {
|
|
1128
|
+
if (typeof uri !== "string")
|
|
1129
|
+
return undefined;
|
|
1077
1130
|
const match = recordUriPattern.exec(uri);
|
|
1078
1131
|
if (match === null || match[1] === undefined || match[2] === undefined)
|
|
1079
1132
|
return undefined;
|
|
1080
1133
|
return { type: match[1], key: match[2] };
|
|
1081
1134
|
}
|
|
1082
1135
|
function recordUri(type, key) {
|
|
1083
|
-
return `autofile
|
|
1136
|
+
return `autofile:///records/${type}/${key}`;
|
|
1084
1137
|
}
|
|
1085
1138
|
function typeUri(type) {
|
|
1086
|
-
return `autofile:///
|
|
1139
|
+
return `autofile:///types/${type}`;
|
|
1087
1140
|
}
|
|
1088
1141
|
function mimeTypeForBlob(name) {
|
|
1089
1142
|
return lookupMimeType(name) || "application/octet-stream";
|
|
1090
1143
|
}
|
|
1091
1144
|
function recordPath(dataFolder, type, key) {
|
|
1092
|
-
return join(dataFolder, type, `${key}.json`);
|
|
1145
|
+
return join(recordFolder(dataFolder, type), `${key}.json`);
|
|
1146
|
+
}
|
|
1147
|
+
function recordFolder(dataFolder, type) {
|
|
1148
|
+
return join(dataFolder, "records", type);
|
|
1093
1149
|
}
|
|
1094
1150
|
function encodeRecordCursor(uri) {
|
|
1095
1151
|
return Buffer.from(uri, "utf8").toString("base64url");
|
|
1096
1152
|
}
|
|
1097
|
-
function decodeRecordCursor(cursor,
|
|
1153
|
+
function decodeRecordCursor(cursor, types) {
|
|
1098
1154
|
if (!/^[A-Za-z0-9_-]+$/u.test(cursor))
|
|
1099
1155
|
return undefined;
|
|
1100
1156
|
const bytes = Buffer.from(cursor, "base64url");
|
|
@@ -1108,7 +1164,7 @@ function decodeRecordCursor(cursor, type) {
|
|
|
1108
1164
|
return undefined;
|
|
1109
1165
|
}
|
|
1110
1166
|
const record = parseRecordUri(uri);
|
|
1111
|
-
return record
|
|
1167
|
+
return record !== undefined && (types === undefined || types.includes(record.type)) ? uri : undefined;
|
|
1112
1168
|
}
|
|
1113
1169
|
function findWhereRefusal(value) {
|
|
1114
1170
|
if (Array.isArray(value)) {
|
|
@@ -1133,11 +1189,23 @@ function findWhereRefusal(value) {
|
|
|
1133
1189
|
}
|
|
1134
1190
|
return undefined;
|
|
1135
1191
|
}
|
|
1136
|
-
function
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return
|
|
1192
|
+
function recordReadError(_error) {
|
|
1193
|
+
return "record is unavailable";
|
|
1194
|
+
}
|
|
1195
|
+
function toError(error) {
|
|
1196
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1197
|
+
}
|
|
1198
|
+
function reportSubscriptionError(subscription, error) {
|
|
1199
|
+
if (subscription.onError === undefined)
|
|
1200
|
+
process.emitWarning(error);
|
|
1201
|
+
else {
|
|
1202
|
+
try {
|
|
1203
|
+
subscription.onError(error);
|
|
1204
|
+
}
|
|
1205
|
+
catch (failure) {
|
|
1206
|
+
process.emitWarning(toError(failure));
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1141
1209
|
}
|
|
1142
1210
|
function stringValuesContainWords(value, words) {
|
|
1143
1211
|
if (words.length === 0)
|
|
@@ -1219,7 +1287,7 @@ function timestampAfter(previous) {
|
|
|
1219
1287
|
return new Date(Math.max(Date.now(), new Date(previous).valueOf() + 1)).toISOString();
|
|
1220
1288
|
}
|
|
1221
1289
|
function refuse(message, uri) {
|
|
1222
|
-
|
|
1290
|
+
throw new AutofileError([{ ...(uri === undefined ? {} : { uri }), message }]);
|
|
1223
1291
|
}
|
|
1224
1292
|
function declarationRefusal(type, result) {
|
|
1225
1293
|
const uri = typeUri(type);
|
|
@@ -1237,6 +1305,8 @@ function recordReadRefusal(uri, result) {
|
|
|
1237
1305
|
throw new Error("A found record is not a refusal");
|
|
1238
1306
|
}
|
|
1239
1307
|
async function recognizedRecordEntries(folder) {
|
|
1308
|
+
if (!await isDirectory(dirname(folder)))
|
|
1309
|
+
return [];
|
|
1240
1310
|
return (await readDirectory(folder))
|
|
1241
1311
|
.filter((entry) => {
|
|
1242
1312
|
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
@@ -1305,26 +1375,45 @@ async function openRegularFile(path) {
|
|
|
1305
1375
|
throw error;
|
|
1306
1376
|
}
|
|
1307
1377
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1378
|
+
/** Keeps a file open only while a bounded, backpressured read is active. */
|
|
1379
|
+
function streamFile(handle, start, end) {
|
|
1380
|
+
let position = start;
|
|
1381
|
+
let closing;
|
|
1382
|
+
const close = () => closing ??= handle.close().catch(() => { throw new Error("Storage operation failed"); });
|
|
1383
|
+
return new ReadableStream({
|
|
1384
|
+
async pull(controller) {
|
|
1385
|
+
try {
|
|
1386
|
+
if (position === end) {
|
|
1387
|
+
await close();
|
|
1388
|
+
controller.close();
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const bytes = Buffer.allocUnsafe(Math.min(64 * 1024, end - position));
|
|
1392
|
+
const { bytesRead } = await handle.read(bytes, 0, bytes.length, position);
|
|
1393
|
+
if (closing !== undefined)
|
|
1394
|
+
return;
|
|
1395
|
+
if (bytesRead === 0) {
|
|
1396
|
+
await close();
|
|
1397
|
+
controller.close();
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
position += bytesRead;
|
|
1401
|
+
controller.enqueue(bytes.subarray(0, bytesRead));
|
|
1402
|
+
}
|
|
1403
|
+
catch {
|
|
1404
|
+
await close();
|
|
1405
|
+
controller.error(new Error("Storage operation failed"));
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
cancel: () => close(),
|
|
1409
|
+
});
|
|
1318
1410
|
}
|
|
1319
|
-
async function readRegularFile(path
|
|
1411
|
+
async function readRegularFile(path) {
|
|
1320
1412
|
const file = await openRegularFile(path);
|
|
1321
1413
|
if (file === undefined)
|
|
1322
1414
|
return undefined;
|
|
1323
1415
|
try {
|
|
1324
|
-
|
|
1325
|
-
return undefined;
|
|
1326
|
-
const bytes = await file.handle.readFile();
|
|
1327
|
-
return bytes.byteLength <= maximumBytes ? bytes : undefined;
|
|
1416
|
+
return await file.handle.readFile();
|
|
1328
1417
|
}
|
|
1329
1418
|
finally {
|
|
1330
1419
|
await file.handle.close();
|
|
@@ -1341,14 +1430,17 @@ async function pathExists(path) {
|
|
|
1341
1430
|
throw error;
|
|
1342
1431
|
}
|
|
1343
1432
|
}
|
|
1344
|
-
function
|
|
1433
|
+
function isTypeDefinition(value) {
|
|
1345
1434
|
if (!isJsonObject(value))
|
|
1346
1435
|
return false;
|
|
1347
|
-
if (Object.keys(value).some((key) => !["description", "titleField", "schema"].includes(key))) {
|
|
1436
|
+
if (Object.keys(value).some((key) => !["description", "title", "titleField", "schema"].includes(key))) {
|
|
1348
1437
|
return false;
|
|
1349
1438
|
}
|
|
1350
1439
|
if (typeof value.description !== "string" || !isJsonObject(value.schema))
|
|
1351
1440
|
return false;
|
|
1441
|
+
if (value.title !== undefined && (typeof value.title !== "string" || value.title.length === 0)) {
|
|
1442
|
+
return false;
|
|
1443
|
+
}
|
|
1352
1444
|
return value.titleField === undefined || typeof value.titleField === "string";
|
|
1353
1445
|
}
|
|
1354
1446
|
function isStoredRecord(value) {
|