@telepath-computer/autofile 0.3.0 → 0.5.0

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