@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/dist/store.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Reads and writes Autofile's on-disk types and records, and exposes recognized
3
- * records, declarations, and blobs as plain resource data. Each operation reads
4
- * current files and keeps protocol-specific behavior outside the store.
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 { BLOB_NAME_PATTERN, BLOB_URI_PATTERN, MAX_BLOB_BYTES, blobNameFromUri, blobUri, deleteBlobUris, prepareBlob, writePreparedBlobs, } from "./blobs.js";
15
- import { getStorage, getWriteLockPath } from "./config.js";
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
- /** Opens a store, creating its data folder when it does not exist. */
30
- export async function openStore(options = {}) {
31
- const dataFolder = resolve(options.dataFolder ?? await getStorage());
32
- const writeLockPath = resolve(options.writeLockPath ?? getWriteLockPath());
33
- await Promise.all([
34
- mkdir(dataFolder, { recursive: true }),
35
- mkdir(dirname(writeLockPath), { recursive: true }),
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
- function createStore(dataFolder, writeLockPath) {
40
- const typeFolder = join(dataFolder, "_types");
41
- const blobFolder = join(dataFolder, "_blobs");
42
- async function withWriteLock(write) {
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 function findRecords(input) {
70
- if (!typeNamePattern.test(input.type))
71
- return refuse(invalidTypeMessage);
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, input.type);
294
+ : decodeRecordCursor(input.cursor, requestedTypes);
97
295
  if (input.cursor !== undefined && afterUri === undefined) {
98
296
  return refuse("cursor: is invalid");
99
297
  }
100
- const declaration = await readDeclaration(typeFolder, input.type);
101
- if (declaration.status !== "found")
102
- return declarationRefusal(input.type, declaration);
103
- const schema = await preparedDeclarationSchema(declaration.value, typeUri(input.type));
104
- if ("errors" in schema)
105
- return schema;
106
- const entries = (await recognizedRecordEntries(join(dataFolder, input.type)))
107
- .map((entry) => ({
108
- entry,
109
- uri: recordUri(input.type, entry.name.slice(0, -".json".length)),
110
- }))
111
- .sort((left, right) => left.uri < right.uri ? -1 : left.uri > right.uri ? 1 : 0);
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 { entry, uri } = current;
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, input.type, entry.name));
336
+ stored = await readStoredRecord(join(this.#dataFolder, type, name));
134
337
  }
135
338
  catch (error) {
136
- resultError = { uri, message: recordFileReadError(error) };
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
- uri,
154
- createdAt: stored.value.createdAt,
155
- updatedAt: stored.value.updatedAt,
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
- errors: nextErrors,
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 function getRecord(input) {
197
- const parsed = parseRecordUri(input.uri);
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(input.uri, stored);
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.error = validationErrors.join("; ");
414
+ result.problems = validationErrors.map((message) => ({ uri, message }));
218
415
  return result;
219
416
  }
220
- async function createRecord(input, options = {}) {
221
- return withWriteLock(async () => {
222
- if (!typeNamePattern.test(input.type))
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 (input.key !== undefined && !keyPattern.test(input.key)) {
421
+ if (options.key !== undefined && (typeof options.key !== "string" || !keyPattern.test(options.key))) {
225
422
  return refuse(invalidKeyMessage);
226
423
  }
227
- if (!isJsonObject(input.record))
424
+ if (!isJsonObject(value))
228
425
  return refuse("record: must be a JSON object");
229
- const declaration = await readDeclaration(typeFolder, input.type);
426
+ const declaration = await readDeclaration(this.#typeFolder, type);
230
427
  if (declaration.status !== "found")
231
- return declarationRefusal(input.type, declaration);
232
- const schema = await preparedDeclarationSchema(declaration.value, typeUri(input.type));
233
- if ("errors" in schema)
234
- return schema;
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
- const uri = input.key === undefined ? undefined : recordUri(input.type, input.key);
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 sourceUri = input.key === undefined ? undefined : recordUri(input.type, input.key);
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
- return { errors: linkErrors };
244
- await mkdir(join(dataFolder, input.type), { recursive: true });
245
- let key = input.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, input.type, key)));
443
+ while (await pathExists(recordPath(this.#dataFolder, type, key)));
250
444
  }
251
- else if (await pathExists(recordPath(dataFolder, input.type, key))) {
252
- return refuse("a record with that key already exists", recordUri(input.type, key));
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(input.type, key);
255
- const materialized = await prepareRecordBlobs(blobFolder, schema, input.record, options.allowLocalPaths ?? true, uri);
256
- if ("errors" in materialized)
257
- return materialized;
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
- await writePreparedBlobs(blobFolder, materialized.blobs);
263
- await writeJsonAtomically(recordPath(dataFolder, input.type, key), {
264
- createdAt: now,
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 function updateRecord(input, options = {}) {
272
- return withWriteLock(async () => {
273
- const parsed = parseRecordUri(input.uri);
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(input.patch))
277
- return refuse("patch: must be a JSON object", input.uri);
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(input.uri, stored);
285
- const value = mergePatch(stored.value.value, input.patch);
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
- return { errors: validationErrors.map((message) => ({ uri: input.uri, message })) };
479
+ throw new AutofileError(validationErrors.map((message) => ({ uri, message })));
292
480
  }
293
- const linkErrors = await missingLinkErrors(dataFolder, typeFolder, schema, value, input.uri);
294
- if (linkErrors.length > 0)
295
- return { errors: linkErrors };
296
- const materialized = await prepareRecordBlobs(blobFolder, schema, value, options.allowLocalPaths ?? true, input.uri);
297
- if ("errors" in materialized)
298
- return materialized;
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
- await writePreparedBlobs(blobFolder, materialized.blobs);
304
- await writeJsonAtomically(path, {
488
+ const updated = {
305
489
  createdAt: stored.value.createdAt,
306
490
  updatedAt: new Date().toISOString(),
307
- value: materialized.value,
308
- });
309
- await deleteUnheldBlobs(dataFolder, typeFolder, previousBlobs);
310
- return { uri: input.uri };
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 function deleteRecord(input) {
314
- return withWriteLock(async () => {
315
- const parsed = parseRecordUri(input.uri);
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", input.uri);
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: input.uri, message: "record does not exist" });
515
+ errors.push({ uri: targetUri, message: "record does not exist" });
331
516
  }
332
517
  else if (target.status === "invalid") {
333
- errors.push({ uri: input.uri, message: target.message });
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: input.uri, message: recordFileReadError(error) });
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
- if ("errors" in schema)
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 === input.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: recordFileReadError(error) });
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, input.uri)) {
560
+ if (containsStringValue(stored.value.value, targetUri)) {
378
561
  errors.push({
379
562
  uri,
380
- message: `record does not match its type and holds ${input.uri} as a string value`,
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 === input.uri)
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
- return { errors };
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
- : "errors" in targetSchema
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 function listTypes(_input) {
427
- const entries = await readDirectory(typeFolder);
615
+ async #listTypes() {
616
+ await this.#ensureReady();
428
617
  const types = [];
429
- for (const entry of entries) {
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 === undefined)
437
- continue;
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
- return { types };
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 function getType(input) {
444
- if (!typeNamePattern.test(input.type))
642
+ async #createType(type, definition) {
643
+ if (typeof type !== "string" || !typeNamePattern.test(type))
445
644
  return refuse(invalidTypeMessage);
446
- const declaration = await readDeclaration(typeFolder, input.type);
447
- if (declaration.status !== "found")
448
- return declarationRefusal(input.type, declaration);
449
- const schema = await preparedDeclarationSchema(declaration.value, typeUri(input.type));
450
- if ("errors" in schema)
451
- return schema;
452
- return {
453
- uri: typeUri(input.type),
454
- description: declaration.value.description,
455
- ...(declaration.value.titleField === undefined
456
- ? {}
457
- : { titleField: declaration.value.titleField }),
458
- schema: declaration.value.schema,
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
- async function createType(input) {
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(input.type));
667
+ return refuse("type already exists", typeUri(type));
475
668
  }
476
- const schema = await prepareDeclarationSchema(input.schema, typeUri(input.type));
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, input.type), { recursive: true }),
671
+ mkdir(this.#typeFolder, { recursive: true }),
672
+ mkdir(join(this.#dataFolder, type), { recursive: true }),
482
673
  ]);
483
- await writeJsonAtomically(declarationPath, {
484
- description: input.description,
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 function updateType(input) {
492
- return withWriteLock(async () => {
493
- if (!typeNamePattern.test(input.type))
494
- return refuse(invalidTypeMessage);
495
- const current = await readDeclaration(typeFolder, input.type);
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(input.type, current);
498
- if (input.description !== undefined && typeof input.description !== "string") {
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
- : input.titleField ?? current.value.titleField;
705
+ : changedTitleField ?? current.value.titleField;
706
+ const title = changedTitle === null ? undefined : changedTitle ?? current.value.title;
512
707
  const declaration = {
513
- description: input.description ?? current.value.description,
708
+ description: description ?? current.value.description,
709
+ ...(title === undefined ? {} : { title }),
514
710
  ...(titleField === undefined ? {} : { titleField }),
515
- schema: input.schema ?? current.value.schema,
711
+ schema: schemaSnapshot ?? snapshotSchema(current.value.schema),
516
712
  };
517
- const schema = await prepareDeclarationSchema(declaration.schema, typeUri(input.type));
518
- if ("errors" in schema)
519
- return schema;
520
- await writeJsonAtomically(join(typeFolder, `${input.type}.json`), declaration);
521
- const errors = await invalidRecords(dataFolder, input.type, schema);
522
- return { uri: typeUri(input.type), errors };
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 function deleteType(input) {
526
- return withWriteLock(async () => {
527
- if (!typeNamePattern.test(input.type))
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, input.type);
726
+ const declaration = await readDeclaration(this.#typeFolder, type);
530
727
  if (declaration.status !== "found")
531
- return declarationRefusal(input.type, declaration);
728
+ return declarationRefusal(type, declaration);
532
729
  try {
533
- await rmdir(join(dataFolder, input.type));
730
+ await rmdir(join(this.#dataFolder, type));
534
731
  }
535
732
  catch (error) {
536
- if (hasCode(error, "ENOTEMPTY") || hasCode(error, "EEXIST")) {
537
- return refuse("type folder is not empty", typeUri(input.type));
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, `${input.type}.json`));
543
- return { uri: typeUri(input.type) };
741
+ await unlink(join(this.#typeFolder, `${type}.json`));
544
742
  });
545
743
  }
546
- async function listResources() {
547
- const declarationEntries = await readDirectory(typeFolder);
548
- const resources = [];
549
- for (const entry of declarationEntries) {
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
- return recordResource(record.type, record.key, declaration, stored.value);
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 recordResource(type, key, declaration, stored) {
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
- mimeType: "application/json",
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
- return { errors: error.messages.map((message) => ({ uri, message })) };
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 prepareRecordBlobs(blobFolder, schema, value, allowLocalPaths, uri) {
740
- const materialized = structuredClone(value);
741
- const preparedBlobs = new Map();
821
+ async function missingBlobErrors(blobFolder, schema, value, uri) {
742
822
  const errors = [];
743
- const visitedPointers = new Set();
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
- const prepared = await prepareBlob(field.value, blobFolder, allowLocalPaths);
751
- if ("message" in prepared) {
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
- finalBlobs.set(fieldValue, prepared);
830
+ const problem = checked.get(field.value);
831
+ if (problem !== undefined)
832
+ errors.push(formatFieldError(uri, field.location, problem));
786
833
  }
787
- if (errors.length > 0)
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
- const prepared = await preparedDeclarationSchema(declaration.value, typeUri(type));
837
- if (!("errors" in prepared))
838
- schema = prepared;
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 readJsonText(join(dataFolder, type, entry.name), "record file");
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
- return (await readValidDeclarationText(typeFolder, type))?.value;
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 { value: result.value, text: result.text };
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
- const record = await readStoredRecord(join(dataFolder, type, entry.name));
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 readJsonText(join(folder, `${type}.json`), "type declaration");
993
+ const result = await readJson(join(folder, `${type}.json`), "type declaration");
974
994
  if (result.status !== "found")
975
995
  return result;
976
- if (!isTypeDeclaration(result.value)) {
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 readStoredRecordText(path) {
1001
+ async function readStoredRecord(path) {
988
1002
  if (!await isDirectory(dirname(path)))
989
1003
  return { status: "missing" };
990
- const result = await readJsonText(path, "record file");
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 file does not contain a valid record" };
1008
+ return { status: "invalid", message: "record is not valid" };
995
1009
  }
996
- return { status: "found", value: result.value, text: result.text };
1010
+ return { status: "found", value: result.value };
997
1011
  }
998
- async function readJsonText(path, label) {
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
- return { status: "found", value: JSON.parse(text), text };
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} cannot be read as JSON` };
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 = `${JSON.stringify(value, null, 2)}\n`;
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, type) {
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?.type === type ? uri : undefined;
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 recordFileReadError(error) {
1137
- const code = error instanceof Error && "code" in error && typeof error.code === "string"
1138
- ? `: ${error.code}`
1139
- : "";
1140
- return `record file cannot be read${code}`;
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
- return { errors: [{ ...(uri === undefined ? {} : { uri }), message }] };
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 isTypeDeclaration(value) {
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) {