@telepath-computer/autofile 0.3.0 → 0.4.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 +33 -19
- package/dist/blobs.d.ts +9 -22
- package/dist/blobs.js +35 -171
- package/dist/blobs.js.map +1 -1
- package/dist/cli.js +15 -9
- package/dist/cli.js.map +1 -1
- package/dist/http.d.ts +2 -2
- package/dist/http.js +10 -5
- 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 +31 -0
- package/dist/mcp-resources.js +111 -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 +2 -2
- package/dist/server.js +58 -38
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +90 -110
- package/dist/store.js +532 -524
- package/dist/store.js.map +1 -1
- package/dist/watch.d.ts +6 -18
- package/dist/watch.js +73 -191
- 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,13 +12,14 @@ 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 { MAX_BLOB_BYTES, blobNameFromUri, deleteBlobUris, blobReferenceProblem, blobSizeProblem, isMediaType, writeBlob, } 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
22
|
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;
|
|
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";
|
|
@@ -26,26 +28,215 @@ const invalidKeyMessage = "key: must use lowercase letters, digits, and hyphens
|
|
|
26
28
|
const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///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 (bytes.byteLength > MAX_BLOB_BYTES)
|
|
95
|
+
return refuse(blobSizeProblem(bytes.byteLength));
|
|
96
|
+
if (!isMediaType(mediaType))
|
|
97
|
+
return refuse("mediaType: must be a valid media type, such as application/pdf");
|
|
98
|
+
const snapshot = Buffer.from(bytes);
|
|
99
|
+
if (snapshot.length > MAX_BLOB_BYTES)
|
|
100
|
+
return refuse(blobSizeProblem(snapshot.length));
|
|
101
|
+
return this.#withWriteLock(() => writeBlob(this.#blobFolder, snapshot, mediaType));
|
|
102
|
+
}),
|
|
103
|
+
get: (uri) => this.#operation(() => this.#getBlob(uri)),
|
|
104
|
+
};
|
|
105
|
+
/** Checks a schema without accessing storage or checking link targets. */
|
|
106
|
+
static async prepareSchema(schema) {
|
|
107
|
+
return prepareDeclarationSchema(snapshotSchema(schema));
|
|
108
|
+
}
|
|
109
|
+
/** Subscribes after the shared watcher is ready; the returned function unsubscribes. */
|
|
110
|
+
async watch(onChange, options = {}) {
|
|
111
|
+
await this.#operation(() => this.#ensureReady());
|
|
112
|
+
// Each call is a subscription, even if callers reuse the same function.
|
|
113
|
+
const listener = { onChange, ...options };
|
|
114
|
+
this.#listeners.add(listener);
|
|
115
|
+
this.#watcher ??= this.#startWatcher();
|
|
116
|
+
try {
|
|
117
|
+
await this.#watcher;
|
|
118
|
+
this.#assertOpen();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.#listeners.delete(listener);
|
|
122
|
+
this.#watcher = undefined;
|
|
123
|
+
if (hasErrorCode(error))
|
|
124
|
+
throw new Error("Change monitoring failed");
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
return async () => { this.#listeners.delete(listener); };
|
|
128
|
+
}
|
|
129
|
+
/** Stops change subscriptions. Future operations on this instance are refused. */
|
|
130
|
+
close() {
|
|
131
|
+
this.#closed = true;
|
|
132
|
+
this.#listeners.clear();
|
|
133
|
+
this.#closing ??= (async () => {
|
|
134
|
+
const watcher = await this.#watcher;
|
|
135
|
+
await this.#operation(async () => watcher?.close());
|
|
136
|
+
})();
|
|
137
|
+
return this.#closing;
|
|
138
|
+
}
|
|
139
|
+
async #operation(run) {
|
|
140
|
+
try {
|
|
141
|
+
return await run();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (hasErrorCode(error))
|
|
145
|
+
throw new Error("Storage operation failed");
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
#assertOpen() {
|
|
150
|
+
if (this.#closed)
|
|
151
|
+
refuse("Autofile is closed");
|
|
152
|
+
}
|
|
153
|
+
async #ensureReady() {
|
|
154
|
+
this.#assertOpen();
|
|
155
|
+
this.#ready ??= Promise.all([
|
|
156
|
+
mkdir(this.#dataFolder, { recursive: true }),
|
|
157
|
+
mkdir(dirname(this.#writeLockPath), { recursive: true }),
|
|
158
|
+
]).then(() => undefined);
|
|
159
|
+
await this.#ready;
|
|
160
|
+
this.#assertOpen();
|
|
161
|
+
}
|
|
162
|
+
#reportWatchError() {
|
|
163
|
+
const failure = new Error("Change monitoring failed");
|
|
164
|
+
for (const listener of this.#listeners)
|
|
165
|
+
reportSubscriptionError(listener, failure);
|
|
166
|
+
}
|
|
167
|
+
async #startWatcher() {
|
|
168
|
+
const watcher = await watchFolder(this.#dataFolder, (changes) => {
|
|
169
|
+
// Delivery is ordered independently of the filesystem watcher so a
|
|
170
|
+
// subscriber may await close without waiting for its own callback.
|
|
171
|
+
this.#notifications = this.#notifications.then(async () => {
|
|
172
|
+
await this.#watcher;
|
|
173
|
+
if (this.#closed)
|
|
174
|
+
return;
|
|
175
|
+
const events = await this.#logicalChanges(changes);
|
|
176
|
+
if (events.length === 0)
|
|
177
|
+
return;
|
|
178
|
+
for (const subscription of [...this.#listeners]) {
|
|
179
|
+
if (!this.#listeners.has(subscription))
|
|
180
|
+
continue;
|
|
181
|
+
try {
|
|
182
|
+
await subscription.onChange(events);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
reportSubscriptionError(subscription, toError(error));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}).catch(() => this.#reportWatchError());
|
|
189
|
+
}, () => this.#reportWatchError());
|
|
190
|
+
try {
|
|
191
|
+
for (const declaration of await this.#listTypes())
|
|
192
|
+
this.#watchTypes.add(declaration.type);
|
|
193
|
+
return watcher;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
await watcher.close();
|
|
197
|
+
throw new Error("Change monitoring failed");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async #logicalChanges(changes) {
|
|
201
|
+
const previousTypes = new Set(this.#watchTypes);
|
|
202
|
+
const uncertainTypes = new Set();
|
|
203
|
+
const events = [];
|
|
204
|
+
for (const change of changes) {
|
|
205
|
+
if (!change.uri.startsWith("autofile:///_types/"))
|
|
206
|
+
continue;
|
|
207
|
+
const type = change.uri.slice("autofile:///_types/".length);
|
|
208
|
+
try {
|
|
209
|
+
const declaration = await readValidDeclaration(this.#typeFolder, type);
|
|
210
|
+
if (declaration === undefined)
|
|
211
|
+
this.#watchTypes.delete(type);
|
|
212
|
+
else
|
|
213
|
+
this.#watchTypes.add(type);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// An unavailable declaration does not prove a type disappeared. Retain
|
|
217
|
+
// known membership and forward this batch's affected hints for rereading.
|
|
218
|
+
uncertainTypes.add(type);
|
|
219
|
+
this.#reportWatchError();
|
|
220
|
+
}
|
|
221
|
+
if (previousTypes.has(type) || this.#watchTypes.has(type) || uncertainTypes.has(type))
|
|
222
|
+
events.push(change);
|
|
223
|
+
}
|
|
224
|
+
for (const change of changes) {
|
|
225
|
+
const record = parseRecordUri(change.uri);
|
|
226
|
+
if (record !== undefined && (previousTypes.has(record.type) || this.#watchTypes.has(record.type) || uncertainTypes.has(record.type))) {
|
|
227
|
+
events.push(change);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return events;
|
|
231
|
+
}
|
|
232
|
+
async #withWriteLock(write) {
|
|
233
|
+
await this.#ensureReady();
|
|
43
234
|
let release;
|
|
44
235
|
let retryDelay = 50;
|
|
45
236
|
while (true) {
|
|
46
237
|
try {
|
|
47
|
-
release = await lockfile.lock(dirname(writeLockPath), {
|
|
48
|
-
lockfilePath: writeLockPath,
|
|
238
|
+
release = await lockfile.lock(dirname(this.#writeLockPath), {
|
|
239
|
+
lockfilePath: this.#writeLockPath,
|
|
49
240
|
stale: 10_000,
|
|
50
241
|
update: 5_000,
|
|
51
242
|
retries: 0,
|
|
@@ -66,9 +257,16 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
66
257
|
await release();
|
|
67
258
|
}
|
|
68
259
|
}
|
|
69
|
-
async
|
|
70
|
-
|
|
71
|
-
|
|
260
|
+
async #findRecords(input) {
|
|
261
|
+
await this.#ensureReady();
|
|
262
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
263
|
+
return refuse("query: must be an object");
|
|
264
|
+
const requestedTypes = input.type === undefined ? undefined
|
|
265
|
+
: typeof input.type === "string" ? [input.type] : input.type;
|
|
266
|
+
if (requestedTypes !== undefined && (!Array.isArray(requestedTypes)
|
|
267
|
+
|| requestedTypes.some((type) => typeof type !== "string" || !typeNamePattern.test(type)))) {
|
|
268
|
+
refuse(invalidTypeMessage);
|
|
269
|
+
}
|
|
72
270
|
if (input.q !== undefined && typeof input.q !== "string") {
|
|
73
271
|
return refuse("q: must be a string");
|
|
74
272
|
}
|
|
@@ -93,22 +291,27 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
93
291
|
return refuse(whereRefusal);
|
|
94
292
|
const afterUri = input.cursor === undefined
|
|
95
293
|
? undefined
|
|
96
|
-
: decodeRecordCursor(input.cursor,
|
|
294
|
+
: decodeRecordCursor(input.cursor, requestedTypes);
|
|
97
295
|
if (input.cursor !== undefined && afterUri === undefined) {
|
|
98
296
|
return refuse("cursor: is invalid");
|
|
99
297
|
}
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
298
|
+
const declarations = requestedTypes === undefined
|
|
299
|
+
? await this.#listTypes()
|
|
300
|
+
: await Promise.all([...new Set(requestedTypes)].map(async (type) => {
|
|
301
|
+
const declaration = await this.#getType(type);
|
|
302
|
+
if (declaration === undefined)
|
|
303
|
+
refuse("type does not exist", typeUri(type));
|
|
304
|
+
return declaration;
|
|
305
|
+
}));
|
|
306
|
+
const entries = [];
|
|
307
|
+
for (const declaration of declarations) {
|
|
308
|
+
const schema = await preparedDeclarationSchema(declaration, declaration.uri);
|
|
309
|
+
for (const entry of await recognizedRecordEntries(join(this.#dataFolder, declaration.type))) {
|
|
310
|
+
const key = entry.name.slice(0, -".json".length);
|
|
311
|
+
entries.push({ type: declaration.type, key, name: entry.name, uri: recordUri(declaration.type, key), declaration, schema });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
entries.sort((left, right) => left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0);
|
|
112
315
|
const start = afterUri === undefined
|
|
113
316
|
? 0
|
|
114
317
|
: entries.findIndex(({ uri }) => uri > afterUri);
|
|
@@ -125,15 +328,15 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
125
328
|
if (current === undefined)
|
|
126
329
|
break;
|
|
127
330
|
nextIndex += 1;
|
|
128
|
-
const {
|
|
331
|
+
const { type, key, name, uri, declaration, schema } = current;
|
|
129
332
|
let stored;
|
|
130
333
|
let record;
|
|
131
334
|
let resultError;
|
|
132
335
|
try {
|
|
133
|
-
stored = await readStoredRecord(join(dataFolder,
|
|
336
|
+
stored = await readStoredRecord(join(this.#dataFolder, type, name));
|
|
134
337
|
}
|
|
135
338
|
catch (error) {
|
|
136
|
-
resultError = { uri, message:
|
|
339
|
+
resultError = { uri, message: recordReadError(error) };
|
|
137
340
|
}
|
|
138
341
|
if (stored !== undefined) {
|
|
139
342
|
if (stored.status !== "found") {
|
|
@@ -149,14 +352,10 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
149
352
|
resultError = { uri, message: validationErrors.join("; ") };
|
|
150
353
|
}
|
|
151
354
|
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
|
-
};
|
|
355
|
+
record = recordResult(type, key, declaration, stored.value);
|
|
356
|
+
if (fields !== undefined) {
|
|
357
|
+
record.value = Object.fromEntries(Object.entries(record.value).filter(([field]) => fields.has(field)));
|
|
358
|
+
}
|
|
160
359
|
}
|
|
161
360
|
}
|
|
162
361
|
}
|
|
@@ -168,7 +367,7 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
168
367
|
const nextErrors = resultError === undefined ? errors : [...errors, resultError];
|
|
169
368
|
const nextPageBytes = Buffer.byteLength(JSON.stringify({
|
|
170
369
|
records: nextRecords,
|
|
171
|
-
|
|
370
|
+
unreadable: nextErrors,
|
|
172
371
|
}), "utf8");
|
|
173
372
|
if (records.length + errors.length > 0 && nextPageBytes > MAX_FIND_RECORDS_PAGE_BYTES) {
|
|
174
373
|
// Reading the candidate is necessary to know its serialized size, but it belongs to the
|
|
@@ -187,182 +386,166 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
187
386
|
}
|
|
188
387
|
return {
|
|
189
388
|
records,
|
|
190
|
-
errors,
|
|
389
|
+
unreadable: errors,
|
|
191
390
|
...(lastReadUri !== undefined && nextIndex < entries.length
|
|
192
391
|
? { cursor: encodeRecordCursor(lastReadUri) }
|
|
193
392
|
: {}),
|
|
194
393
|
};
|
|
195
394
|
}
|
|
196
|
-
async
|
|
197
|
-
|
|
395
|
+
async #getRecord(uri) {
|
|
396
|
+
await this.#ensureReady();
|
|
397
|
+
const parsed = parseRecordUri(uri);
|
|
198
398
|
if (parsed === undefined)
|
|
199
399
|
return refuse(invalidRecordUriMessage);
|
|
200
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
400
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
401
|
+
if (declaration.status === "missing")
|
|
402
|
+
return undefined;
|
|
201
403
|
if (declaration.status !== "found")
|
|
202
404
|
return declarationRefusal(parsed.type, declaration);
|
|
203
|
-
const stored = await readStoredRecord(recordPath(dataFolder, parsed.type, parsed.key));
|
|
405
|
+
const stored = await readStoredRecord(recordPath(this.#dataFolder, parsed.type, parsed.key));
|
|
406
|
+
if (stored.status === "missing")
|
|
407
|
+
return undefined;
|
|
204
408
|
if (stored.status !== "found")
|
|
205
|
-
return recordReadRefusal(
|
|
409
|
+
return recordReadRefusal(uri, stored);
|
|
206
410
|
const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
207
|
-
if ("errors" in schema)
|
|
208
|
-
return schema;
|
|
209
411
|
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
|
-
};
|
|
412
|
+
const result = recordResult(parsed.type, parsed.key, declaration.value, stored.value);
|
|
216
413
|
if (validationErrors.length > 0)
|
|
217
|
-
result.
|
|
414
|
+
result.problems = validationErrors.map((message) => ({ uri, message }));
|
|
218
415
|
return result;
|
|
219
416
|
}
|
|
220
|
-
async
|
|
221
|
-
return withWriteLock(async () => {
|
|
222
|
-
if (!typeNamePattern.test(
|
|
417
|
+
async #createRecord(type, value, options) {
|
|
418
|
+
return this.#withWriteLock(async () => {
|
|
419
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
223
420
|
return refuse(invalidTypeMessage);
|
|
224
|
-
if (
|
|
421
|
+
if (options.key !== undefined && (typeof options.key !== "string" || !keyPattern.test(options.key))) {
|
|
225
422
|
return refuse(invalidKeyMessage);
|
|
226
423
|
}
|
|
227
|
-
if (!isJsonObject(
|
|
424
|
+
if (!isJsonObject(value))
|
|
228
425
|
return refuse("record: must be a JSON object");
|
|
229
|
-
const declaration = await readDeclaration(typeFolder,
|
|
426
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
230
427
|
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);
|
|
428
|
+
return declarationRefusal(type, declaration);
|
|
429
|
+
const schema = await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
430
|
+
const sourceUri = options.key === undefined ? undefined : recordUri(type, options.key);
|
|
431
|
+
const validationErrors = await schema.validate(value);
|
|
236
432
|
if (validationErrors.length > 0) {
|
|
237
|
-
|
|
238
|
-
return { errors: validationErrors.map((message) => ({ ...(uri === undefined ? {} : { uri }), message })) };
|
|
433
|
+
throw new AutofileError(validationErrors.map((message) => ({ ...(sourceUri === undefined ? {} : { uri: sourceUri }), message })));
|
|
239
434
|
}
|
|
240
|
-
const
|
|
241
|
-
const linkErrors = await missingLinkErrors(dataFolder, typeFolder, schema, input.record, sourceUri);
|
|
435
|
+
const linkErrors = await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, sourceUri);
|
|
242
436
|
if (linkErrors.length > 0)
|
|
243
|
-
|
|
244
|
-
await mkdir(join(dataFolder,
|
|
245
|
-
let key =
|
|
437
|
+
throw new AutofileError(linkErrors);
|
|
438
|
+
await mkdir(join(this.#dataFolder, type), { recursive: true });
|
|
439
|
+
let key = options.key;
|
|
246
440
|
if (key === undefined) {
|
|
247
441
|
do
|
|
248
442
|
key = nextTid();
|
|
249
|
-
while (await pathExists(recordPath(dataFolder,
|
|
443
|
+
while (await pathExists(recordPath(this.#dataFolder, type, key)));
|
|
250
444
|
}
|
|
251
|
-
else if (await pathExists(recordPath(dataFolder,
|
|
252
|
-
return refuse("a record with that key already exists", recordUri(
|
|
445
|
+
else if (await pathExists(recordPath(this.#dataFolder, type, key))) {
|
|
446
|
+
return refuse("a record with that key already exists", recordUri(type, key));
|
|
253
447
|
}
|
|
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 };
|
|
448
|
+
const uri = recordUri(type, key);
|
|
449
|
+
const blobErrors = await missingBlobErrors(this.#blobFolder, schema, value, uri);
|
|
450
|
+
if (blobErrors.length > 0)
|
|
451
|
+
throw new AutofileError(blobErrors);
|
|
261
452
|
const now = new Date().toISOString();
|
|
262
|
-
|
|
263
|
-
await writeJsonAtomically(recordPath(dataFolder,
|
|
264
|
-
|
|
265
|
-
updatedAt: now,
|
|
266
|
-
value: materialized.value,
|
|
267
|
-
});
|
|
268
|
-
return { uri };
|
|
453
|
+
const stored = { createdAt: now, updatedAt: now, value };
|
|
454
|
+
await writeJsonAtomically(recordPath(this.#dataFolder, type, key), stored);
|
|
455
|
+
return recordResult(type, key, declaration.value, stored);
|
|
269
456
|
});
|
|
270
457
|
}
|
|
271
|
-
async
|
|
272
|
-
return withWriteLock(async () => {
|
|
273
|
-
const parsed = parseRecordUri(
|
|
458
|
+
async #updateRecord(uri, patch) {
|
|
459
|
+
return this.#withWriteLock(async () => {
|
|
460
|
+
const parsed = parseRecordUri(uri);
|
|
274
461
|
if (parsed === undefined)
|
|
275
462
|
return refuse(invalidRecordUriMessage);
|
|
276
|
-
if (!isJsonObject(
|
|
277
|
-
return refuse("patch: must be a JSON object",
|
|
278
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
463
|
+
if (!isJsonObject(patch))
|
|
464
|
+
return refuse("patch: must be a JSON object", uri);
|
|
465
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
279
466
|
if (declaration.status !== "found")
|
|
280
467
|
return declarationRefusal(parsed.type, declaration);
|
|
281
|
-
const path = recordPath(dataFolder, parsed.type, parsed.key);
|
|
468
|
+
const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
|
|
282
469
|
const stored = await readStoredRecord(path);
|
|
283
470
|
if (stored.status !== "found")
|
|
284
|
-
return recordReadRefusal(
|
|
285
|
-
const value = mergePatch(stored.value.value,
|
|
471
|
+
return recordReadRefusal(uri, stored);
|
|
472
|
+
const value = mergePatch(stored.value.value, patch);
|
|
473
|
+
const problem = jsonObjectProblem(value);
|
|
474
|
+
if (problem !== undefined)
|
|
475
|
+
return refuse(`record: ${problem}`, uri);
|
|
286
476
|
const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
287
|
-
if ("errors" in schema)
|
|
288
|
-
return schema;
|
|
289
477
|
const validationErrors = await schema.validate(value);
|
|
290
478
|
if (validationErrors.length > 0) {
|
|
291
|
-
|
|
479
|
+
throw new AutofileError(validationErrors.map((message) => ({ uri, message })));
|
|
292
480
|
}
|
|
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 };
|
|
481
|
+
const errors = [
|
|
482
|
+
...await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, uri),
|
|
483
|
+
...await missingBlobErrors(this.#blobFolder, schema, value, uri),
|
|
484
|
+
];
|
|
485
|
+
if (errors.length > 0)
|
|
486
|
+
throw new AutofileError(errors);
|
|
302
487
|
const previousBlobs = await blobsHeldByRecord(stored.value.value, schema);
|
|
303
|
-
|
|
304
|
-
await writeJsonAtomically(path, {
|
|
488
|
+
const updated = {
|
|
305
489
|
createdAt: stored.value.createdAt,
|
|
306
490
|
updatedAt: new Date().toISOString(),
|
|
307
|
-
value
|
|
308
|
-
}
|
|
309
|
-
await
|
|
310
|
-
|
|
491
|
+
value,
|
|
492
|
+
};
|
|
493
|
+
await writeJsonAtomically(path, updated);
|
|
494
|
+
await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
|
|
495
|
+
return recordResult(parsed.type, parsed.key, declaration.value, updated);
|
|
311
496
|
});
|
|
312
497
|
}
|
|
313
|
-
async
|
|
314
|
-
return withWriteLock(async () => {
|
|
315
|
-
const parsed = parseRecordUri(
|
|
498
|
+
async #deleteRecord(targetUri) {
|
|
499
|
+
return this.#withWriteLock(async () => {
|
|
500
|
+
const parsed = parseRecordUri(targetUri);
|
|
316
501
|
if (parsed === undefined)
|
|
317
502
|
return refuse(invalidRecordUriMessage);
|
|
318
|
-
const declaration = await readDeclaration(typeFolder, parsed.type);
|
|
503
|
+
const declaration = await readDeclaration(this.#typeFolder, parsed.type);
|
|
319
504
|
if (declaration.status !== "found")
|
|
320
505
|
return declarationRefusal(parsed.type, declaration);
|
|
321
|
-
const path = recordPath(dataFolder, parsed.type, parsed.key);
|
|
506
|
+
const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
|
|
322
507
|
if (!await isRegularFile(path))
|
|
323
|
-
return refuse("record does not exist",
|
|
508
|
+
return refuse("record does not exist", targetUri);
|
|
324
509
|
const changes = [];
|
|
325
510
|
const errors = [];
|
|
326
511
|
let deletedRecord;
|
|
327
512
|
try {
|
|
328
513
|
const target = await readStoredRecord(path);
|
|
329
514
|
if (target.status === "missing") {
|
|
330
|
-
errors.push({ uri:
|
|
515
|
+
errors.push({ uri: targetUri, message: "record does not exist" });
|
|
331
516
|
}
|
|
332
517
|
else if (target.status === "invalid") {
|
|
333
|
-
errors.push({ uri:
|
|
518
|
+
errors.push({ uri: targetUri, message: target.message });
|
|
334
519
|
}
|
|
335
520
|
else {
|
|
336
521
|
deletedRecord = target.value;
|
|
337
522
|
}
|
|
338
523
|
}
|
|
339
524
|
catch (error) {
|
|
340
|
-
errors.push({ uri:
|
|
525
|
+
errors.push({ uri: targetUri, message: recordReadError(error) });
|
|
341
526
|
}
|
|
342
|
-
const typeEntries = (await readDirectory(typeFolder))
|
|
527
|
+
const typeEntries = (await readDirectory(this.#typeFolder))
|
|
343
528
|
.filter((entry) => (entry.isFile()
|
|
344
529
|
&& entry.name.endsWith(".json")
|
|
345
530
|
&& typeNamePattern.test(entry.name.slice(0, -".json".length))))
|
|
346
531
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
347
532
|
for (const typeEntry of typeEntries) {
|
|
348
533
|
const type = typeEntry.name.slice(0, -".json".length);
|
|
349
|
-
const otherDeclaration = await readValidDeclaration(typeFolder, type);
|
|
534
|
+
const otherDeclaration = await readValidDeclaration(this.#typeFolder, type);
|
|
350
535
|
if (otherDeclaration === undefined)
|
|
351
536
|
continue;
|
|
352
537
|
const schema = await preparedDeclarationSchema(otherDeclaration, typeUri(type));
|
|
353
|
-
|
|
354
|
-
continue;
|
|
355
|
-
for (const entry of await recognizedRecordEntries(join(dataFolder, type))) {
|
|
538
|
+
for (const entry of await recognizedRecordEntries(join(this.#dataFolder, type))) {
|
|
356
539
|
const key = entry.name.slice(0, -".json".length);
|
|
357
540
|
const uri = recordUri(type, key);
|
|
358
|
-
if (uri ===
|
|
541
|
+
if (uri === targetUri)
|
|
359
542
|
continue;
|
|
360
543
|
let stored;
|
|
361
544
|
try {
|
|
362
|
-
stored = await readStoredRecord(join(dataFolder, type, entry.name));
|
|
545
|
+
stored = await readStoredRecord(join(this.#dataFolder, type, entry.name));
|
|
363
546
|
}
|
|
364
547
|
catch (error) {
|
|
365
|
-
errors.push({ uri, message:
|
|
548
|
+
errors.push({ uri, message: recordReadError(error) });
|
|
366
549
|
continue;
|
|
367
550
|
}
|
|
368
551
|
if (stored.status !== "found") {
|
|
@@ -374,16 +557,16 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
374
557
|
}
|
|
375
558
|
const validationErrors = await schema.validate(stored.value.value);
|
|
376
559
|
if (validationErrors.length > 0) {
|
|
377
|
-
if (containsStringValue(stored.value.value,
|
|
560
|
+
if (containsStringValue(stored.value.value, targetUri)) {
|
|
378
561
|
errors.push({
|
|
379
562
|
uri,
|
|
380
|
-
message: `record does not match its type and holds ${
|
|
563
|
+
message: `record does not match its type and holds ${targetUri} as a string value`,
|
|
381
564
|
});
|
|
382
565
|
}
|
|
383
566
|
continue;
|
|
384
567
|
}
|
|
385
568
|
const pointers = schema.formatFields(stored.value.value, "uri")
|
|
386
|
-
.filter((field) => field.value ===
|
|
569
|
+
.filter((field) => field.value === targetUri)
|
|
387
570
|
.map(({ pointer }) => pointer);
|
|
388
571
|
if (pointers.length === 0)
|
|
389
572
|
continue;
|
|
@@ -394,7 +577,7 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
394
577
|
continue;
|
|
395
578
|
}
|
|
396
579
|
changes.push({
|
|
397
|
-
path: join(dataFolder, type, entry.name),
|
|
580
|
+
path: join(this.#dataFolder, type, entry.name),
|
|
398
581
|
record: {
|
|
399
582
|
createdAt: stored.value.createdAt,
|
|
400
583
|
updatedAt: timestampAfter(stored.value.updatedAt),
|
|
@@ -408,302 +591,201 @@ function createStore(dataFolder, writeLockPath) {
|
|
|
408
591
|
const uriOrder = (left.uri ?? "").localeCompare(right.uri ?? "");
|
|
409
592
|
return uriOrder === 0 ? left.message.localeCompare(right.message) : uriOrder;
|
|
410
593
|
});
|
|
411
|
-
|
|
594
|
+
throw new AutofileError(errors);
|
|
595
|
+
}
|
|
596
|
+
let targetSchema;
|
|
597
|
+
try {
|
|
598
|
+
targetSchema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (!(error instanceof AutofileError))
|
|
602
|
+
throw error;
|
|
412
603
|
}
|
|
413
|
-
const targetSchema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
|
|
414
604
|
const previousBlobs = deletedRecord === undefined
|
|
415
605
|
? new Set()
|
|
416
|
-
:
|
|
606
|
+
: targetSchema === undefined
|
|
417
607
|
? blobUrisIn(deletedRecord.value)
|
|
418
608
|
: await blobsHeldByRecord(deletedRecord.value, targetSchema);
|
|
419
609
|
for (const change of changes)
|
|
420
610
|
await writeJsonAtomically(change.path, change.record);
|
|
421
611
|
await unlink(path);
|
|
422
|
-
await deleteUnheldBlobs(dataFolder, typeFolder, previousBlobs);
|
|
423
|
-
return { uri: input.uri };
|
|
612
|
+
await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
|
|
424
613
|
});
|
|
425
614
|
}
|
|
426
|
-
async
|
|
427
|
-
|
|
615
|
+
async #listTypes() {
|
|
616
|
+
await this.#ensureReady();
|
|
428
617
|
const types = [];
|
|
429
|
-
for (const entry of
|
|
618
|
+
for (const entry of await readDirectory(this.#typeFolder)) {
|
|
430
619
|
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
431
620
|
continue;
|
|
432
621
|
const type = entry.name.slice(0, -".json".length);
|
|
433
622
|
if (!typeNamePattern.test(type))
|
|
434
623
|
continue;
|
|
435
|
-
const declaration = await readValidDeclaration(typeFolder, type);
|
|
436
|
-
if (declaration
|
|
437
|
-
|
|
438
|
-
types.push({ uri: typeUri(type), type, description: declaration.description });
|
|
624
|
+
const declaration = await readValidDeclaration(this.#typeFolder, type);
|
|
625
|
+
if (declaration !== undefined)
|
|
626
|
+
types.push(typeResult(type, declaration));
|
|
439
627
|
}
|
|
440
|
-
types.sort((left, right) => left.type.localeCompare(right.type));
|
|
441
|
-
|
|
628
|
+
return types.sort((left, right) => left.type.localeCompare(right.type));
|
|
629
|
+
}
|
|
630
|
+
async #getType(type) {
|
|
631
|
+
await this.#ensureReady();
|
|
632
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
633
|
+
refuse(invalidTypeMessage);
|
|
634
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
635
|
+
if (declaration.status === "missing")
|
|
636
|
+
return undefined;
|
|
637
|
+
if (declaration.status !== "found")
|
|
638
|
+
return declarationRefusal(type, declaration);
|
|
639
|
+
await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
640
|
+
return typeResult(type, declaration.value);
|
|
442
641
|
}
|
|
443
|
-
async
|
|
444
|
-
if (!typeNamePattern.test(
|
|
642
|
+
async #createType(type, definition) {
|
|
643
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
445
644
|
return refuse(invalidTypeMessage);
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
if ("
|
|
451
|
-
return
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
645
|
+
if (!isJsonObject(definition))
|
|
646
|
+
return refuse("declaration: must be an object");
|
|
647
|
+
const { description, titleField } = definition;
|
|
648
|
+
const title = readTypeTitle(definition);
|
|
649
|
+
if (typeof description !== "string")
|
|
650
|
+
return refuse("description: must be a string");
|
|
651
|
+
if (titleField !== undefined && typeof titleField !== "string") {
|
|
652
|
+
return refuse("titleField: must be a string");
|
|
653
|
+
}
|
|
654
|
+
if (title !== undefined && (typeof title !== "string" || title.length === 0)) {
|
|
655
|
+
return refuse("title: must be a non-empty string");
|
|
656
|
+
}
|
|
657
|
+
// Validate and copy caller-owned data before acquiring the asynchronous lock.
|
|
658
|
+
const declaration = {
|
|
659
|
+
description,
|
|
660
|
+
...(title === undefined ? {} : { title }),
|
|
661
|
+
...(titleField === undefined ? {} : { titleField }),
|
|
662
|
+
schema: snapshotSchema(definition.schema),
|
|
459
663
|
};
|
|
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`);
|
|
664
|
+
return this.#withWriteLock(async () => {
|
|
665
|
+
const declarationPath = join(this.#typeFolder, `${type}.json`);
|
|
473
666
|
if (await pathExists(declarationPath)) {
|
|
474
|
-
return refuse("type already exists", typeUri(
|
|
667
|
+
return refuse("type already exists", typeUri(type));
|
|
475
668
|
}
|
|
476
|
-
|
|
477
|
-
if ("errors" in schema)
|
|
478
|
-
return schema;
|
|
669
|
+
await prepareDeclarationSchema(declaration.schema, typeUri(type));
|
|
479
670
|
await Promise.all([
|
|
480
|
-
mkdir(typeFolder, { recursive: true }),
|
|
481
|
-
mkdir(join(dataFolder,
|
|
671
|
+
mkdir(this.#typeFolder, { recursive: true }),
|
|
672
|
+
mkdir(join(this.#dataFolder, type), { recursive: true }),
|
|
482
673
|
]);
|
|
483
|
-
await writeJsonAtomically(declarationPath,
|
|
484
|
-
|
|
485
|
-
...(input.titleField === undefined ? {} : { titleField: input.titleField }),
|
|
486
|
-
schema: input.schema,
|
|
487
|
-
});
|
|
488
|
-
return { uri: typeUri(input.type) };
|
|
674
|
+
await writeJsonAtomically(declarationPath, declaration);
|
|
675
|
+
return typeResult(type, declaration);
|
|
489
676
|
});
|
|
490
677
|
}
|
|
491
|
-
async
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
678
|
+
async #updateType(type, changes) {
|
|
679
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
680
|
+
return refuse(invalidTypeMessage);
|
|
681
|
+
if (!isJsonObject(changes))
|
|
682
|
+
return refuse("changes: must be an object");
|
|
683
|
+
const { description, titleField: changedTitleField, schema: changedSchema } = changes;
|
|
684
|
+
const changedTitle = readTypeTitle(changes);
|
|
685
|
+
if (description !== undefined && typeof description !== "string") {
|
|
686
|
+
return refuse("description: must be a string");
|
|
687
|
+
}
|
|
688
|
+
if (changedTitleField !== undefined
|
|
689
|
+
&& changedTitleField !== null
|
|
690
|
+
&& typeof changedTitleField !== "string") {
|
|
691
|
+
return refuse("titleField: must be a string or null");
|
|
692
|
+
}
|
|
693
|
+
if (changedTitle !== undefined
|
|
694
|
+
&& changedTitle !== null
|
|
695
|
+
&& (typeof changedTitle !== "string" || changedTitle.length === 0)) {
|
|
696
|
+
return refuse("title: must be a non-empty string or null");
|
|
697
|
+
}
|
|
698
|
+
const schemaSnapshot = changedSchema === undefined ? undefined : snapshotSchema(changedSchema);
|
|
699
|
+
return this.#withWriteLock(async () => {
|
|
700
|
+
const current = await readDeclaration(this.#typeFolder, type);
|
|
496
701
|
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
|
|
702
|
+
return declarationRefusal(type, current);
|
|
703
|
+
const titleField = changedTitleField === null
|
|
510
704
|
? undefined
|
|
511
|
-
:
|
|
705
|
+
: changedTitleField ?? current.value.titleField;
|
|
706
|
+
const title = changedTitle === null ? undefined : changedTitle ?? current.value.title;
|
|
512
707
|
const declaration = {
|
|
513
|
-
description:
|
|
708
|
+
description: description ?? current.value.description,
|
|
709
|
+
...(title === undefined ? {} : { title }),
|
|
514
710
|
...(titleField === undefined ? {} : { titleField }),
|
|
515
|
-
schema:
|
|
711
|
+
schema: schemaSnapshot ?? snapshotSchema(current.value.schema),
|
|
516
712
|
};
|
|
517
|
-
const schema = await prepareDeclarationSchema(declaration.schema, typeUri(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
713
|
+
const schema = await prepareDeclarationSchema(declaration.schema, typeUri(type));
|
|
714
|
+
await writeJsonAtomically(join(this.#typeFolder, `${type}.json`), declaration);
|
|
715
|
+
const errors = await invalidRecords(this.#dataFolder, type, schema);
|
|
716
|
+
const result = typeResult(type, declaration);
|
|
717
|
+
if (errors.length > 0)
|
|
718
|
+
result.problems = errors;
|
|
719
|
+
return result;
|
|
523
720
|
});
|
|
524
721
|
}
|
|
525
|
-
async
|
|
526
|
-
return withWriteLock(async () => {
|
|
527
|
-
if (!typeNamePattern.test(
|
|
722
|
+
async #deleteType(type) {
|
|
723
|
+
return this.#withWriteLock(async () => {
|
|
724
|
+
if (typeof type !== "string" || !typeNamePattern.test(type))
|
|
528
725
|
return refuse(invalidTypeMessage);
|
|
529
|
-
const declaration = await readDeclaration(typeFolder,
|
|
726
|
+
const declaration = await readDeclaration(this.#typeFolder, type);
|
|
530
727
|
if (declaration.status !== "found")
|
|
531
|
-
return declarationRefusal(
|
|
728
|
+
return declarationRefusal(type, declaration);
|
|
532
729
|
try {
|
|
533
|
-
await rmdir(join(dataFolder,
|
|
730
|
+
await rmdir(join(this.#dataFolder, type));
|
|
534
731
|
}
|
|
535
732
|
catch (error) {
|
|
536
|
-
|
|
537
|
-
|
|
733
|
+
// Keep the declaration whenever any stored data remains, including
|
|
734
|
+
// entries that cannot be recognized or read as records.
|
|
735
|
+
if (["ENOTDIR", "ENOTEMPTY", "EEXIST"].some((code) => hasCode(error, code))) {
|
|
736
|
+
return refuse("type still has stored data", typeUri(type));
|
|
538
737
|
}
|
|
539
738
|
if (!hasCode(error, "ENOENT"))
|
|
540
739
|
throw error;
|
|
541
740
|
}
|
|
542
|
-
await unlink(join(typeFolder, `${
|
|
543
|
-
return { uri: typeUri(input.type) };
|
|
741
|
+
await unlink(join(this.#typeFolder, `${type}.json`));
|
|
544
742
|
});
|
|
545
743
|
}
|
|
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")
|
|
744
|
+
async #getBlob(uri) {
|
|
745
|
+
await this.#ensureReady();
|
|
746
|
+
const name = blobNameFromUri(uri);
|
|
747
|
+
if (name === undefined || !await isDirectory(this.#blobFolder))
|
|
583
748
|
return undefined;
|
|
584
|
-
|
|
749
|
+
const bytes = await readRegularFile(join(this.#blobFolder, name), MAX_BLOB_BYTES);
|
|
750
|
+
return bytes === undefined ? undefined : { bytes, mimeType: mimeTypeForBlob(name) };
|
|
585
751
|
}
|
|
586
|
-
async function listTypeResources(type) {
|
|
587
|
-
if (!typeNamePattern.test(type))
|
|
588
|
-
return [];
|
|
589
|
-
const declaration = await readValidDeclaration(typeFolder, type);
|
|
590
|
-
if (declaration === undefined)
|
|
591
|
-
return [];
|
|
592
|
-
const resources = [declarationResource(type, declaration)];
|
|
593
|
-
const recordEntries = await recognizedRecordEntries(join(dataFolder, type));
|
|
594
|
-
for (const entry of recordEntries) {
|
|
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 };
|
|
641
|
-
}
|
|
642
|
-
const blobMatch = BLOB_URI_PATTERN.exec(uri);
|
|
643
|
-
const blobName = blobMatch?.[1];
|
|
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)
|
|
649
|
-
return undefined;
|
|
650
|
-
return { uri, mimeType: mimeTypeForBlob(blobName), blob: bytes.toString("base64") };
|
|
651
|
-
}
|
|
652
|
-
const record = parseRecordUri(uri);
|
|
653
|
-
if (record === undefined)
|
|
654
|
-
return undefined;
|
|
655
|
-
if (await readValidDeclaration(typeFolder, record.type) === undefined)
|
|
656
|
-
return undefined;
|
|
657
|
-
const stored = await readStoredRecordText(recordPath(dataFolder, record.type, record.key));
|
|
658
|
-
if (stored.status !== "found")
|
|
659
|
-
return undefined;
|
|
660
|
-
return { uri, mimeType: "application/json", text: stored.text };
|
|
661
|
-
}
|
|
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
|
-
}
|
|
680
|
-
function declarationResource(type, declaration) {
|
|
681
|
-
return {
|
|
682
|
-
uri: typeUri(type),
|
|
683
|
-
name: `_types/${type}`,
|
|
684
|
-
mimeType: "application/json",
|
|
685
|
-
description: declaration.description,
|
|
686
|
-
};
|
|
687
752
|
}
|
|
688
|
-
function
|
|
689
|
-
const title = declaration.titleField === undefined
|
|
690
|
-
? undefined
|
|
691
|
-
: stored.value[declaration.titleField];
|
|
753
|
+
function recordResult(type, key, declaration, stored) {
|
|
754
|
+
const title = declaration.titleField === undefined ? undefined : stored.value[declaration.titleField];
|
|
692
755
|
return {
|
|
693
|
-
uri: recordUri(type, key),
|
|
694
|
-
name: `${type}/${key}`,
|
|
756
|
+
uri: recordUri(type, key), type, key,
|
|
695
757
|
...(typeof title === "string" ? { title } : {}),
|
|
696
|
-
|
|
697
|
-
lastModified: stored.updatedAt,
|
|
758
|
+
...stored,
|
|
698
759
|
};
|
|
699
760
|
}
|
|
761
|
+
function typeResult(type, definition) {
|
|
762
|
+
return { uri: typeUri(type), type, ...definition };
|
|
763
|
+
}
|
|
764
|
+
function jsonText(value) { return `${JSON.stringify(value, null, 2)}\n`; }
|
|
765
|
+
function readTypeTitle(input) {
|
|
766
|
+
// Read the descriptor so an accessor cannot run caller code or supply a value
|
|
767
|
+
// that JSON serialization would omit. Validation and snapshotting stay synchronous.
|
|
768
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, "title");
|
|
769
|
+
if (descriptor === undefined)
|
|
770
|
+
return undefined;
|
|
771
|
+
if (!("value" in descriptor) || !descriptor.enumerable) {
|
|
772
|
+
refuse("title: must be an enumerable data property");
|
|
773
|
+
}
|
|
774
|
+
return descriptor.value;
|
|
775
|
+
}
|
|
776
|
+
function snapshotSchema(schema) {
|
|
777
|
+
const problem = jsonObjectProblem(schema);
|
|
778
|
+
if (problem !== undefined)
|
|
779
|
+
refuse(`schema: ${problem}`);
|
|
780
|
+
return JSON.parse(JSON.stringify(schema));
|
|
781
|
+
}
|
|
700
782
|
async function prepareDeclarationSchema(schema, uri) {
|
|
701
783
|
try {
|
|
702
784
|
return await prepareSchema(schema);
|
|
703
785
|
}
|
|
704
786
|
catch (error) {
|
|
705
787
|
if (error instanceof SchemaRefusal) {
|
|
706
|
-
|
|
788
|
+
throw new AutofileError(error.messages.map((message) => ({ ...(uri === undefined ? {} : { uri }), message })));
|
|
707
789
|
}
|
|
708
790
|
throw error;
|
|
709
791
|
}
|
|
@@ -736,57 +818,20 @@ async function missingLinkErrors(dataFolder, typeFolder, schema, value, sourceUr
|
|
|
736
818
|
}
|
|
737
819
|
return errors;
|
|
738
820
|
}
|
|
739
|
-
async function
|
|
740
|
-
const materialized = structuredClone(value);
|
|
741
|
-
const preparedBlobs = new Map();
|
|
821
|
+
async function missingBlobErrors(blobFolder, schema, value, uri) {
|
|
742
822
|
const errors = [];
|
|
743
|
-
const
|
|
823
|
+
const checked = new Map();
|
|
744
824
|
for (const field of schema.formatFields(value, "blob")) {
|
|
745
|
-
if (visitedPointers.has(field.pointer))
|
|
746
|
-
continue;
|
|
747
|
-
visitedPointers.add(field.pointer);
|
|
748
825
|
if (typeof field.value !== "string")
|
|
749
826
|
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;
|
|
827
|
+
if (!checked.has(field.value)) {
|
|
828
|
+
checked.set(field.value, await blobReferenceProblem(blobFolder, field.value));
|
|
784
829
|
}
|
|
785
|
-
|
|
830
|
+
const problem = checked.get(field.value);
|
|
831
|
+
if (problem !== undefined)
|
|
832
|
+
errors.push(formatFieldError(uri, field.location, problem));
|
|
786
833
|
}
|
|
787
|
-
|
|
788
|
-
return { errors };
|
|
789
|
-
return { value: materialized, blobs: [...finalBlobs.values()] };
|
|
834
|
+
return errors;
|
|
790
835
|
}
|
|
791
836
|
function formatFieldError(uri, location, message) {
|
|
792
837
|
return { uri, message: location === "" ? message : `${location}: ${message}` };
|
|
@@ -833,9 +878,13 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
|
|
|
833
878
|
}
|
|
834
879
|
let schema;
|
|
835
880
|
if (declaration.status === "found") {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
881
|
+
try {
|
|
882
|
+
schema = await preparedDeclarationSchema(declaration.value, typeUri(type));
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
if (!(error instanceof AutofileError))
|
|
886
|
+
throw error;
|
|
887
|
+
}
|
|
839
888
|
}
|
|
840
889
|
let recordEntries;
|
|
841
890
|
try {
|
|
@@ -849,7 +898,7 @@ async function blobsHeldInStore(dataFolder, typeFolder) {
|
|
|
849
898
|
for (const entry of recordEntries) {
|
|
850
899
|
let record;
|
|
851
900
|
try {
|
|
852
|
-
record = await
|
|
901
|
+
record = await readJson(join(dataFolder, type, entry.name), "record");
|
|
853
902
|
}
|
|
854
903
|
catch (error) {
|
|
855
904
|
if (hasErrorCode(error))
|
|
@@ -891,33 +940,6 @@ function collectBlobUris(value, uris) {
|
|
|
891
940
|
collectBlobUris(item, uris);
|
|
892
941
|
}
|
|
893
942
|
}
|
|
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
943
|
async function recordExists(dataFolder, typeFolder, uri) {
|
|
922
944
|
const parsed = parseRecordUri(uri);
|
|
923
945
|
if (parsed === undefined)
|
|
@@ -928,10 +950,7 @@ async function recordExists(dataFolder, typeFolder, uri) {
|
|
|
928
950
|
return stored.status === "found";
|
|
929
951
|
}
|
|
930
952
|
async function readValidDeclaration(typeFolder, type) {
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
async function readValidDeclarationText(typeFolder, type) {
|
|
934
|
-
const result = await readDeclarationText(typeFolder, type);
|
|
953
|
+
const result = await readDeclaration(typeFolder, type);
|
|
935
954
|
if (result.status !== "found")
|
|
936
955
|
return undefined;
|
|
937
956
|
try {
|
|
@@ -942,7 +961,7 @@ async function readValidDeclarationText(typeFolder, type) {
|
|
|
942
961
|
return undefined;
|
|
943
962
|
throw error;
|
|
944
963
|
}
|
|
945
|
-
return
|
|
964
|
+
return result.value;
|
|
946
965
|
}
|
|
947
966
|
async function invalidRecords(dataFolder, type, schema) {
|
|
948
967
|
const errors = [];
|
|
@@ -950,7 +969,14 @@ async function invalidRecords(dataFolder, type, schema) {
|
|
|
950
969
|
for (const entry of entries) {
|
|
951
970
|
const key = entry.name.slice(0, -".json".length);
|
|
952
971
|
const uri = recordUri(type, key);
|
|
953
|
-
|
|
972
|
+
let record;
|
|
973
|
+
try {
|
|
974
|
+
record = await readStoredRecord(join(dataFolder, type, entry.name));
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
errors.push({ uri, message: recordReadError(error) });
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
954
980
|
if (record.status !== "found") {
|
|
955
981
|
errors.push({ uri, message: record.status === "invalid" ? record.message : "record does not exist" });
|
|
956
982
|
continue;
|
|
@@ -962,60 +988,42 @@ async function invalidRecords(dataFolder, type, schema) {
|
|
|
962
988
|
return errors;
|
|
963
989
|
}
|
|
964
990
|
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
991
|
if (!await isDirectory(folder))
|
|
972
992
|
return { status: "missing" };
|
|
973
|
-
const result = await
|
|
993
|
+
const result = await readJson(join(folder, `${type}.json`), "type declaration");
|
|
974
994
|
if (result.status !== "found")
|
|
975
995
|
return result;
|
|
976
|
-
if (!
|
|
996
|
+
if (!isTypeDefinition(result.value)) {
|
|
977
997
|
return { status: "invalid", message: "type declaration is not valid" };
|
|
978
998
|
}
|
|
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
999
|
return { status: "found", value: result.value };
|
|
986
1000
|
}
|
|
987
|
-
async function
|
|
1001
|
+
async function readStoredRecord(path) {
|
|
988
1002
|
if (!await isDirectory(dirname(path)))
|
|
989
1003
|
return { status: "missing" };
|
|
990
|
-
const result = await
|
|
1004
|
+
const result = await readJson(path, "record");
|
|
991
1005
|
if (result.status !== "found")
|
|
992
1006
|
return result;
|
|
993
1007
|
if (!isStoredRecord(result.value)) {
|
|
994
|
-
return { status: "invalid", message: "record
|
|
1008
|
+
return { status: "invalid", message: "record is not valid" };
|
|
995
1009
|
}
|
|
996
|
-
return { status: "found", value: result.value
|
|
1010
|
+
return { status: "found", value: result.value };
|
|
997
1011
|
}
|
|
998
|
-
async function
|
|
1012
|
+
async function readJson(path, label) {
|
|
999
1013
|
const bytes = await readRegularFile(path);
|
|
1000
1014
|
if (bytes === undefined)
|
|
1001
1015
|
return { status: "missing" };
|
|
1002
|
-
let text;
|
|
1003
|
-
try {
|
|
1004
|
-
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1005
|
-
}
|
|
1006
|
-
catch {
|
|
1007
|
-
return { status: "invalid", message: `${label} is not valid UTF-8` };
|
|
1008
|
-
}
|
|
1009
1016
|
try {
|
|
1010
|
-
|
|
1017
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
1018
|
+
return { status: "found", value: JSON.parse(text) };
|
|
1011
1019
|
}
|
|
1012
1020
|
catch {
|
|
1013
|
-
return { status: "invalid", message: `${label}
|
|
1021
|
+
return { status: "invalid", message: `${label} is unavailable` };
|
|
1014
1022
|
}
|
|
1015
1023
|
}
|
|
1016
1024
|
async function writeJsonAtomically(path, value) {
|
|
1017
1025
|
const temporaryPath = join(dirname(path), `.${path.slice(path.lastIndexOf("/") + 1)}.${process.pid}.${randomUUID()}`);
|
|
1018
|
-
const text =
|
|
1026
|
+
const text = jsonText(value);
|
|
1019
1027
|
try {
|
|
1020
1028
|
await writeFile(temporaryPath, text, { encoding: "utf8", flag: "wx" });
|
|
1021
1029
|
await rename(temporaryPath, path);
|
|
@@ -1067,13 +1075,9 @@ function nextTid() {
|
|
|
1067
1075
|
}
|
|
1068
1076
|
return tid;
|
|
1069
1077
|
}
|
|
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
1078
|
function parseRecordUri(uri) {
|
|
1079
|
+
if (typeof uri !== "string")
|
|
1080
|
+
return undefined;
|
|
1077
1081
|
const match = recordUriPattern.exec(uri);
|
|
1078
1082
|
if (match === null || match[1] === undefined || match[2] === undefined)
|
|
1079
1083
|
return undefined;
|
|
@@ -1094,7 +1098,7 @@ function recordPath(dataFolder, type, key) {
|
|
|
1094
1098
|
function encodeRecordCursor(uri) {
|
|
1095
1099
|
return Buffer.from(uri, "utf8").toString("base64url");
|
|
1096
1100
|
}
|
|
1097
|
-
function decodeRecordCursor(cursor,
|
|
1101
|
+
function decodeRecordCursor(cursor, types) {
|
|
1098
1102
|
if (!/^[A-Za-z0-9_-]+$/u.test(cursor))
|
|
1099
1103
|
return undefined;
|
|
1100
1104
|
const bytes = Buffer.from(cursor, "base64url");
|
|
@@ -1108,7 +1112,7 @@ function decodeRecordCursor(cursor, type) {
|
|
|
1108
1112
|
return undefined;
|
|
1109
1113
|
}
|
|
1110
1114
|
const record = parseRecordUri(uri);
|
|
1111
|
-
return record
|
|
1115
|
+
return record !== undefined && (types === undefined || types.includes(record.type)) ? uri : undefined;
|
|
1112
1116
|
}
|
|
1113
1117
|
function findWhereRefusal(value) {
|
|
1114
1118
|
if (Array.isArray(value)) {
|
|
@@ -1133,11 +1137,23 @@ function findWhereRefusal(value) {
|
|
|
1133
1137
|
}
|
|
1134
1138
|
return undefined;
|
|
1135
1139
|
}
|
|
1136
|
-
function
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return
|
|
1140
|
+
function recordReadError(_error) {
|
|
1141
|
+
return "record is unavailable";
|
|
1142
|
+
}
|
|
1143
|
+
function toError(error) {
|
|
1144
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
1145
|
+
}
|
|
1146
|
+
function reportSubscriptionError(subscription, error) {
|
|
1147
|
+
if (subscription.onError === undefined)
|
|
1148
|
+
process.emitWarning(error);
|
|
1149
|
+
else {
|
|
1150
|
+
try {
|
|
1151
|
+
subscription.onError(error);
|
|
1152
|
+
}
|
|
1153
|
+
catch (failure) {
|
|
1154
|
+
process.emitWarning(toError(failure));
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1141
1157
|
}
|
|
1142
1158
|
function stringValuesContainWords(value, words) {
|
|
1143
1159
|
if (words.length === 0)
|
|
@@ -1219,7 +1235,7 @@ function timestampAfter(previous) {
|
|
|
1219
1235
|
return new Date(Math.max(Date.now(), new Date(previous).valueOf() + 1)).toISOString();
|
|
1220
1236
|
}
|
|
1221
1237
|
function refuse(message, uri) {
|
|
1222
|
-
|
|
1238
|
+
throw new AutofileError([{ ...(uri === undefined ? {} : { uri }), message }]);
|
|
1223
1239
|
}
|
|
1224
1240
|
function declarationRefusal(type, result) {
|
|
1225
1241
|
const uri = typeUri(type);
|
|
@@ -1305,17 +1321,6 @@ async function openRegularFile(path) {
|
|
|
1305
1321
|
throw error;
|
|
1306
1322
|
}
|
|
1307
1323
|
}
|
|
1308
|
-
async function regularFileSize(path, maximumBytes) {
|
|
1309
|
-
const file = await openRegularFile(path);
|
|
1310
|
-
if (file === undefined)
|
|
1311
|
-
return undefined;
|
|
1312
|
-
try {
|
|
1313
|
-
return file.size <= maximumBytes ? file.size : undefined;
|
|
1314
|
-
}
|
|
1315
|
-
finally {
|
|
1316
|
-
await file.handle.close();
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
1324
|
async function readRegularFile(path, maximumBytes = Number.POSITIVE_INFINITY) {
|
|
1320
1325
|
const file = await openRegularFile(path);
|
|
1321
1326
|
if (file === undefined)
|
|
@@ -1341,14 +1346,17 @@ async function pathExists(path) {
|
|
|
1341
1346
|
throw error;
|
|
1342
1347
|
}
|
|
1343
1348
|
}
|
|
1344
|
-
function
|
|
1349
|
+
function isTypeDefinition(value) {
|
|
1345
1350
|
if (!isJsonObject(value))
|
|
1346
1351
|
return false;
|
|
1347
|
-
if (Object.keys(value).some((key) => !["description", "titleField", "schema"].includes(key))) {
|
|
1352
|
+
if (Object.keys(value).some((key) => !["description", "title", "titleField", "schema"].includes(key))) {
|
|
1348
1353
|
return false;
|
|
1349
1354
|
}
|
|
1350
1355
|
if (typeof value.description !== "string" || !isJsonObject(value.schema))
|
|
1351
1356
|
return false;
|
|
1357
|
+
if (value.title !== undefined && (typeof value.title !== "string" || value.title.length === 0)) {
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1352
1360
|
return value.titleField === undefined || typeof value.titleField === "string";
|
|
1353
1361
|
}
|
|
1354
1362
|
function isStoredRecord(value) {
|