@telepath-computer/autofile 0.0.0-stage → 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 ADDED
@@ -0,0 +1,1389 @@
1
+ /**
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.
6
+ */
7
+ import { randomBytes, randomUUID } from "node:crypto";
8
+ import { constants } from "node:fs";
9
+ import { lstat, mkdir, open, readdir, rename, rmdir, unlink, writeFile, } from "node:fs/promises";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { setTimeout as delay } from "node:timers/promises";
12
+ import { lookup as lookupMimeType } from "mime-types";
13
+ import { Query } from "mingo";
14
+ import lockfile from "proper-lockfile";
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";
18
+ import { prepareSchema, SchemaRefusal } from "./schema.js";
19
+ import { watchFolder } from "./watch.js";
20
+ const typeNamePattern = /^[a-z][a-z0-9-]*$/u;
21
+ const keyPattern = /^[a-z0-9][a-z0-9-]*$/u;
22
+ const recordUriPattern = /^autofile:\/\/\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9-]*)$/u;
23
+ const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u;
24
+ const MAX_FIND_RECORDS_PAGE_BYTES = 64 * 1024;
25
+ const tidAlphabet = "234567abcdefghijklmnopqrstuvwxyz";
26
+ const invalidTypeMessage = "type: must use lowercase letters, digits, and hyphens and start with a letter";
27
+ const invalidKeyMessage = "key: must use lowercase letters, digits, and hyphens and start with a letter or digit";
28
+ const invalidRecordUriMessage = "uri: must be a record URI, such as autofile:///task/book-the-dentist";
29
+ const clockIdentifier = BigInt(randomBytes(2).readUInt16BE() & 0x3ff);
30
+ let lastTidTimestamp = 0n;
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
+ }
39
+ }
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();
234
+ let release;
235
+ let retryDelay = 50;
236
+ while (true) {
237
+ try {
238
+ release = await lockfile.lock(dirname(this.#writeLockPath), {
239
+ lockfilePath: this.#writeLockPath,
240
+ stale: 10_000,
241
+ update: 5_000,
242
+ retries: 0,
243
+ });
244
+ break;
245
+ }
246
+ catch (error) {
247
+ if (!hasCode(error, "ELOCKED"))
248
+ throw error;
249
+ await delay(retryDelay);
250
+ retryDelay = Math.min(Math.ceil(retryDelay * 1.2), 1_000);
251
+ }
252
+ }
253
+ try {
254
+ return await write();
255
+ }
256
+ finally {
257
+ await release();
258
+ }
259
+ }
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
+ }
270
+ if (input.q !== undefined && typeof input.q !== "string") {
271
+ return refuse("q: must be a string");
272
+ }
273
+ if (input.where !== undefined && !isJsonObject(input.where)) {
274
+ return refuse("where: must be a JSON object");
275
+ }
276
+ const limit = input.limit ?? 50;
277
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
278
+ return refuse("limit: must be an integer from 1 to 200");
279
+ }
280
+ if (input.fields !== undefined
281
+ && (!Array.isArray(input.fields) || input.fields.some((field) => (typeof field !== "string" || field.length === 0)))) {
282
+ return refuse("fields: must be an array of non-empty strings");
283
+ }
284
+ if (input.cursor !== undefined && typeof input.cursor !== "string") {
285
+ return refuse("cursor: must be a string");
286
+ }
287
+ const whereRefusal = input.where === undefined
288
+ ? undefined
289
+ : findWhereRefusal(input.where);
290
+ if (whereRefusal !== undefined)
291
+ return refuse(whereRefusal);
292
+ const afterUri = input.cursor === undefined
293
+ ? undefined
294
+ : decodeRecordCursor(input.cursor, requestedTypes);
295
+ if (input.cursor !== undefined && afterUri === undefined) {
296
+ return refuse("cursor: is invalid");
297
+ }
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);
315
+ const start = afterUri === undefined
316
+ ? 0
317
+ : entries.findIndex(({ uri }) => uri > afterUri);
318
+ const firstPageIndex = start < 0 ? entries.length : start;
319
+ const words = input.q?.trim().toLowerCase().split(/\s+/u).filter(Boolean) ?? [];
320
+ const query = new Query(input.where ?? {}, { scriptEnabled: false });
321
+ const fields = input.fields === undefined ? undefined : new Set(input.fields);
322
+ const records = [];
323
+ const errors = [];
324
+ let nextIndex = firstPageIndex;
325
+ let lastReadUri;
326
+ while (nextIndex < entries.length && records.length + errors.length < limit) {
327
+ const current = entries[nextIndex];
328
+ if (current === undefined)
329
+ break;
330
+ nextIndex += 1;
331
+ const { type, key, name, uri, declaration, schema } = current;
332
+ let stored;
333
+ let record;
334
+ let resultError;
335
+ try {
336
+ stored = await readStoredRecord(join(this.#dataFolder, type, name));
337
+ }
338
+ catch (error) {
339
+ resultError = { uri, message: recordReadError(error) };
340
+ }
341
+ if (stored !== undefined) {
342
+ if (stored.status !== "found") {
343
+ resultError = {
344
+ uri,
345
+ message: stored.status === "invalid" ? stored.message : "record does not exist",
346
+ };
347
+ }
348
+ else if (stringValuesContainWords(stored.value.value, words)
349
+ && query.test(stored.value.value)) {
350
+ const validationErrors = await schema.validate(stored.value.value);
351
+ if (validationErrors.length > 0) {
352
+ resultError = { uri, message: validationErrors.join("; ") };
353
+ }
354
+ else {
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
+ }
359
+ }
360
+ }
361
+ }
362
+ if (record === undefined && resultError === undefined) {
363
+ lastReadUri = uri;
364
+ continue;
365
+ }
366
+ const nextRecords = record === undefined ? records : [...records, record];
367
+ const nextErrors = resultError === undefined ? errors : [...errors, resultError];
368
+ const nextPageBytes = Buffer.byteLength(JSON.stringify({
369
+ records: nextRecords,
370
+ unreadable: nextErrors,
371
+ }), "utf8");
372
+ if (records.length + errors.length > 0 && nextPageBytes > MAX_FIND_RECORDS_PAGE_BYTES) {
373
+ // Reading the candidate is necessary to know its serialized size, but it belongs to the
374
+ // next page. Leave its index unconsumed so the cursor resumes before it instead of dropping
375
+ // it; only a page's first result may exceed the cap.
376
+ nextIndex -= 1;
377
+ break;
378
+ }
379
+ if (record !== undefined)
380
+ records.push(record);
381
+ if (resultError !== undefined)
382
+ errors.push(resultError);
383
+ lastReadUri = uri;
384
+ if (nextPageBytes >= MAX_FIND_RECORDS_PAGE_BYTES)
385
+ break;
386
+ }
387
+ return {
388
+ records,
389
+ unreadable: errors,
390
+ ...(lastReadUri !== undefined && nextIndex < entries.length
391
+ ? { cursor: encodeRecordCursor(lastReadUri) }
392
+ : {}),
393
+ };
394
+ }
395
+ async #getRecord(uri) {
396
+ await this.#ensureReady();
397
+ const parsed = parseRecordUri(uri);
398
+ if (parsed === undefined)
399
+ return refuse(invalidRecordUriMessage);
400
+ const declaration = await readDeclaration(this.#typeFolder, parsed.type);
401
+ if (declaration.status === "missing")
402
+ return undefined;
403
+ if (declaration.status !== "found")
404
+ return declarationRefusal(parsed.type, declaration);
405
+ const stored = await readStoredRecord(recordPath(this.#dataFolder, parsed.type, parsed.key));
406
+ if (stored.status === "missing")
407
+ return undefined;
408
+ if (stored.status !== "found")
409
+ return recordReadRefusal(uri, stored);
410
+ const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
411
+ const validationErrors = await schema.validate(stored.value.value);
412
+ const result = recordResult(parsed.type, parsed.key, declaration.value, stored.value);
413
+ if (validationErrors.length > 0)
414
+ result.problems = validationErrors.map((message) => ({ uri, message }));
415
+ return result;
416
+ }
417
+ async #createRecord(type, value, options) {
418
+ return this.#withWriteLock(async () => {
419
+ if (typeof type !== "string" || !typeNamePattern.test(type))
420
+ return refuse(invalidTypeMessage);
421
+ if (options.key !== undefined && (typeof options.key !== "string" || !keyPattern.test(options.key))) {
422
+ return refuse(invalidKeyMessage);
423
+ }
424
+ if (!isJsonObject(value))
425
+ return refuse("record: must be a JSON object");
426
+ const declaration = await readDeclaration(this.#typeFolder, type);
427
+ if (declaration.status !== "found")
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);
432
+ if (validationErrors.length > 0) {
433
+ throw new AutofileError(validationErrors.map((message) => ({ ...(sourceUri === undefined ? {} : { uri: sourceUri }), message })));
434
+ }
435
+ const linkErrors = await missingLinkErrors(this.#dataFolder, this.#typeFolder, schema, value, sourceUri);
436
+ if (linkErrors.length > 0)
437
+ throw new AutofileError(linkErrors);
438
+ await mkdir(join(this.#dataFolder, type), { recursive: true });
439
+ let key = options.key;
440
+ if (key === undefined) {
441
+ do
442
+ key = nextTid();
443
+ while (await pathExists(recordPath(this.#dataFolder, type, key)));
444
+ }
445
+ else if (await pathExists(recordPath(this.#dataFolder, type, key))) {
446
+ return refuse("a record with that key already exists", recordUri(type, key));
447
+ }
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);
452
+ const now = new Date().toISOString();
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);
456
+ });
457
+ }
458
+ async #updateRecord(uri, patch) {
459
+ return this.#withWriteLock(async () => {
460
+ const parsed = parseRecordUri(uri);
461
+ if (parsed === undefined)
462
+ return refuse(invalidRecordUriMessage);
463
+ if (!isJsonObject(patch))
464
+ return refuse("patch: must be a JSON object", uri);
465
+ const declaration = await readDeclaration(this.#typeFolder, parsed.type);
466
+ if (declaration.status !== "found")
467
+ return declarationRefusal(parsed.type, declaration);
468
+ const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
469
+ const stored = await readStoredRecord(path);
470
+ if (stored.status !== "found")
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);
476
+ const schema = await preparedDeclarationSchema(declaration.value, typeUri(parsed.type));
477
+ const validationErrors = await schema.validate(value);
478
+ if (validationErrors.length > 0) {
479
+ throw new AutofileError(validationErrors.map((message) => ({ uri, message })));
480
+ }
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);
487
+ const previousBlobs = await blobsHeldByRecord(stored.value.value, schema);
488
+ const updated = {
489
+ createdAt: stored.value.createdAt,
490
+ updatedAt: new Date().toISOString(),
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);
496
+ });
497
+ }
498
+ async #deleteRecord(targetUri) {
499
+ return this.#withWriteLock(async () => {
500
+ const parsed = parseRecordUri(targetUri);
501
+ if (parsed === undefined)
502
+ return refuse(invalidRecordUriMessage);
503
+ const declaration = await readDeclaration(this.#typeFolder, parsed.type);
504
+ if (declaration.status !== "found")
505
+ return declarationRefusal(parsed.type, declaration);
506
+ const path = recordPath(this.#dataFolder, parsed.type, parsed.key);
507
+ if (!await isRegularFile(path))
508
+ return refuse("record does not exist", targetUri);
509
+ const changes = [];
510
+ const errors = [];
511
+ let deletedRecord;
512
+ try {
513
+ const target = await readStoredRecord(path);
514
+ if (target.status === "missing") {
515
+ errors.push({ uri: targetUri, message: "record does not exist" });
516
+ }
517
+ else if (target.status === "invalid") {
518
+ errors.push({ uri: targetUri, message: target.message });
519
+ }
520
+ else {
521
+ deletedRecord = target.value;
522
+ }
523
+ }
524
+ catch (error) {
525
+ errors.push({ uri: targetUri, message: recordReadError(error) });
526
+ }
527
+ const typeEntries = (await readDirectory(this.#typeFolder))
528
+ .filter((entry) => (entry.isFile()
529
+ && entry.name.endsWith(".json")
530
+ && typeNamePattern.test(entry.name.slice(0, -".json".length))))
531
+ .sort((left, right) => left.name.localeCompare(right.name));
532
+ for (const typeEntry of typeEntries) {
533
+ const type = typeEntry.name.slice(0, -".json".length);
534
+ const otherDeclaration = await readValidDeclaration(this.#typeFolder, type);
535
+ if (otherDeclaration === undefined)
536
+ continue;
537
+ const schema = await preparedDeclarationSchema(otherDeclaration, typeUri(type));
538
+ for (const entry of await recognizedRecordEntries(join(this.#dataFolder, type))) {
539
+ const key = entry.name.slice(0, -".json".length);
540
+ const uri = recordUri(type, key);
541
+ if (uri === targetUri)
542
+ continue;
543
+ let stored;
544
+ try {
545
+ stored = await readStoredRecord(join(this.#dataFolder, type, entry.name));
546
+ }
547
+ catch (error) {
548
+ errors.push({ uri, message: recordReadError(error) });
549
+ continue;
550
+ }
551
+ if (stored.status !== "found") {
552
+ errors.push({
553
+ uri,
554
+ message: stored.status === "invalid" ? stored.message : "record does not exist",
555
+ });
556
+ continue;
557
+ }
558
+ const validationErrors = await schema.validate(stored.value.value);
559
+ if (validationErrors.length > 0) {
560
+ if (containsStringValue(stored.value.value, targetUri)) {
561
+ errors.push({
562
+ uri,
563
+ message: `record does not match its type and holds ${targetUri} as a string value`,
564
+ });
565
+ }
566
+ continue;
567
+ }
568
+ const pointers = schema.formatFields(stored.value.value, "uri")
569
+ .filter((field) => field.value === targetUri)
570
+ .map(({ pointer }) => pointer);
571
+ if (pointers.length === 0)
572
+ continue;
573
+ const value = removeFields(stored.value.value, pointers);
574
+ const remainingErrors = await schema.validate(value);
575
+ if (remainingErrors.length > 0) {
576
+ errors.push({ uri, message: remainingErrors.join("; ") });
577
+ continue;
578
+ }
579
+ changes.push({
580
+ path: join(this.#dataFolder, type, entry.name),
581
+ record: {
582
+ createdAt: stored.value.createdAt,
583
+ updatedAt: timestampAfter(stored.value.updatedAt),
584
+ value,
585
+ },
586
+ });
587
+ }
588
+ }
589
+ if (errors.length > 0) {
590
+ errors.sort((left, right) => {
591
+ const uriOrder = (left.uri ?? "").localeCompare(right.uri ?? "");
592
+ return uriOrder === 0 ? left.message.localeCompare(right.message) : uriOrder;
593
+ });
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;
603
+ }
604
+ const previousBlobs = deletedRecord === undefined
605
+ ? new Set()
606
+ : targetSchema === undefined
607
+ ? blobUrisIn(deletedRecord.value)
608
+ : await blobsHeldByRecord(deletedRecord.value, targetSchema);
609
+ for (const change of changes)
610
+ await writeJsonAtomically(change.path, change.record);
611
+ await unlink(path);
612
+ await deleteUnheldBlobs(this.#dataFolder, this.#typeFolder, previousBlobs);
613
+ });
614
+ }
615
+ async #listTypes() {
616
+ await this.#ensureReady();
617
+ const types = [];
618
+ for (const entry of await readDirectory(this.#typeFolder)) {
619
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
620
+ continue;
621
+ const type = entry.name.slice(0, -".json".length);
622
+ if (!typeNamePattern.test(type))
623
+ continue;
624
+ const declaration = await readValidDeclaration(this.#typeFolder, type);
625
+ if (declaration !== undefined)
626
+ types.push(typeResult(type, declaration));
627
+ }
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);
641
+ }
642
+ async #createType(type, definition) {
643
+ if (typeof type !== "string" || !typeNamePattern.test(type))
644
+ return refuse(invalidTypeMessage);
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),
663
+ };
664
+ return this.#withWriteLock(async () => {
665
+ const declarationPath = join(this.#typeFolder, `${type}.json`);
666
+ if (await pathExists(declarationPath)) {
667
+ return refuse("type already exists", typeUri(type));
668
+ }
669
+ await prepareDeclarationSchema(declaration.schema, typeUri(type));
670
+ await Promise.all([
671
+ mkdir(this.#typeFolder, { recursive: true }),
672
+ mkdir(join(this.#dataFolder, type), { recursive: true }),
673
+ ]);
674
+ await writeJsonAtomically(declarationPath, declaration);
675
+ return typeResult(type, declaration);
676
+ });
677
+ }
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);
701
+ if (current.status !== "found")
702
+ return declarationRefusal(type, current);
703
+ const titleField = changedTitleField === null
704
+ ? undefined
705
+ : changedTitleField ?? current.value.titleField;
706
+ const title = changedTitle === null ? undefined : changedTitle ?? current.value.title;
707
+ const declaration = {
708
+ description: description ?? current.value.description,
709
+ ...(title === undefined ? {} : { title }),
710
+ ...(titleField === undefined ? {} : { titleField }),
711
+ schema: schemaSnapshot ?? snapshotSchema(current.value.schema),
712
+ };
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;
720
+ });
721
+ }
722
+ async #deleteType(type) {
723
+ return this.#withWriteLock(async () => {
724
+ if (typeof type !== "string" || !typeNamePattern.test(type))
725
+ return refuse(invalidTypeMessage);
726
+ const declaration = await readDeclaration(this.#typeFolder, type);
727
+ if (declaration.status !== "found")
728
+ return declarationRefusal(type, declaration);
729
+ try {
730
+ await rmdir(join(this.#dataFolder, type));
731
+ }
732
+ catch (error) {
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));
737
+ }
738
+ if (!hasCode(error, "ENOENT"))
739
+ throw error;
740
+ }
741
+ await unlink(join(this.#typeFolder, `${type}.json`));
742
+ });
743
+ }
744
+ async #getBlob(uri) {
745
+ await this.#ensureReady();
746
+ const name = blobNameFromUri(uri);
747
+ if (name === undefined || !await isDirectory(this.#blobFolder))
748
+ return undefined;
749
+ const bytes = await readRegularFile(join(this.#blobFolder, name), MAX_BLOB_BYTES);
750
+ return bytes === undefined ? undefined : { bytes, mimeType: mimeTypeForBlob(name) };
751
+ }
752
+ }
753
+ function recordResult(type, key, declaration, stored) {
754
+ const title = declaration.titleField === undefined ? undefined : stored.value[declaration.titleField];
755
+ return {
756
+ uri: recordUri(type, key), type, key,
757
+ ...(typeof title === "string" ? { title } : {}),
758
+ ...stored,
759
+ };
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
+ }
782
+ async function prepareDeclarationSchema(schema, uri) {
783
+ try {
784
+ return await prepareSchema(schema);
785
+ }
786
+ catch (error) {
787
+ if (error instanceof SchemaRefusal) {
788
+ throw new AutofileError(error.messages.map((message) => ({ ...(uri === undefined ? {} : { uri }), message })));
789
+ }
790
+ throw error;
791
+ }
792
+ }
793
+ async function preparedDeclarationSchema(declaration, uri) {
794
+ return prepareDeclarationSchema(declaration.schema, uri);
795
+ }
796
+ async function missingLinkErrors(dataFolder, typeFolder, schema, value, sourceUri) {
797
+ const errors = [];
798
+ const existence = new Map();
799
+ const checkedFields = new Set();
800
+ for (const field of schema.formatFields(value, "uri")) {
801
+ if (typeof field.value !== "string" || !/^autofile:/iu.test(field.value))
802
+ continue;
803
+ const fieldKey = `${field.pointer}\0${field.value}`;
804
+ if (checkedFields.has(fieldKey))
805
+ continue;
806
+ checkedFields.add(fieldKey);
807
+ let exists = existence.get(field.value);
808
+ if (exists === undefined) {
809
+ exists = await recordExists(dataFolder, typeFolder, field.value);
810
+ existence.set(field.value, exists);
811
+ }
812
+ if (exists)
813
+ continue;
814
+ errors.push({
815
+ ...(sourceUri === undefined ? {} : { uri: sourceUri }),
816
+ message: `${field.location}: links to a record that does not exist: ${field.value}`,
817
+ });
818
+ }
819
+ return errors;
820
+ }
821
+ async function missingBlobErrors(blobFolder, schema, value, uri) {
822
+ const errors = [];
823
+ const checked = new Map();
824
+ for (const field of schema.formatFields(value, "blob")) {
825
+ if (typeof field.value !== "string")
826
+ continue;
827
+ if (!checked.has(field.value)) {
828
+ checked.set(field.value, await blobReferenceProblem(blobFolder, field.value));
829
+ }
830
+ const problem = checked.get(field.value);
831
+ if (problem !== undefined)
832
+ errors.push(formatFieldError(uri, field.location, problem));
833
+ }
834
+ return errors;
835
+ }
836
+ function formatFieldError(uri, location, message) {
837
+ return { uri, message: location === "" ? message : `${location}: ${message}` };
838
+ }
839
+ async function blobsHeldByRecord(value, schema) {
840
+ const validationErrors = await schema.validate(value);
841
+ if (validationErrors.length > 0)
842
+ return blobUrisIn(value);
843
+ return new Set(schema.formatFields(value, "blob").flatMap(({ value: fieldValue }) => (typeof fieldValue === "string" && blobNameFromUri(fieldValue) !== undefined
844
+ ? [fieldValue]
845
+ : [])));
846
+ }
847
+ async function deleteUnheldBlobs(dataFolder, typeFolder, candidates) {
848
+ if (candidates.size === 0)
849
+ return;
850
+ const held = await blobsHeldInStore(dataFolder, typeFolder);
851
+ if (held === undefined)
852
+ return;
853
+ await deleteBlobUris(join(dataFolder, "_blobs"), [...candidates].filter((uri) => !held.has(uri)));
854
+ }
855
+ async function blobsHeldInStore(dataFolder, typeFolder) {
856
+ const held = new Set();
857
+ let typeEntries;
858
+ try {
859
+ typeEntries = (await readDirectory(typeFolder)).filter((entry) => (entry.isFile() && entry.name.endsWith(".json")));
860
+ }
861
+ catch (error) {
862
+ if (hasErrorCode(error))
863
+ return undefined;
864
+ throw error;
865
+ }
866
+ for (const typeEntry of typeEntries) {
867
+ const type = typeEntry.name.slice(0, -".json".length);
868
+ if (!typeNamePattern.test(type))
869
+ continue;
870
+ let declaration;
871
+ try {
872
+ declaration = await readDeclaration(typeFolder, type);
873
+ }
874
+ catch (error) {
875
+ if (!hasErrorCode(error))
876
+ throw error;
877
+ declaration = { status: "invalid", message: "type declaration cannot be read" };
878
+ }
879
+ let schema;
880
+ if (declaration.status === "found") {
881
+ try {
882
+ schema = await preparedDeclarationSchema(declaration.value, typeUri(type));
883
+ }
884
+ catch (error) {
885
+ if (!(error instanceof AutofileError))
886
+ throw error;
887
+ }
888
+ }
889
+ let recordEntries;
890
+ try {
891
+ recordEntries = await recognizedRecordEntries(join(dataFolder, type));
892
+ }
893
+ catch (error) {
894
+ if (hasErrorCode(error))
895
+ return undefined;
896
+ throw error;
897
+ }
898
+ for (const entry of recordEntries) {
899
+ let record;
900
+ try {
901
+ record = await readJson(join(dataFolder, type, entry.name), "record");
902
+ }
903
+ catch (error) {
904
+ if (hasErrorCode(error))
905
+ return undefined;
906
+ throw error;
907
+ }
908
+ if (record.status === "invalid")
909
+ return undefined;
910
+ if (record.status === "missing")
911
+ continue;
912
+ if (schema !== undefined && isStoredRecord(record.value)) {
913
+ for (const uri of await blobsHeldByRecord(record.value.value, schema))
914
+ held.add(uri);
915
+ }
916
+ else {
917
+ for (const uri of blobUrisIn(record.value))
918
+ held.add(uri);
919
+ }
920
+ }
921
+ }
922
+ return held;
923
+ }
924
+ function blobUrisIn(value) {
925
+ const uris = new Set();
926
+ collectBlobUris(value, uris);
927
+ return uris;
928
+ }
929
+ function collectBlobUris(value, uris) {
930
+ if (typeof value === "string") {
931
+ if (blobNameFromUri(value) !== undefined)
932
+ uris.add(value);
933
+ }
934
+ else if (Array.isArray(value)) {
935
+ for (const item of value)
936
+ collectBlobUris(item, uris);
937
+ }
938
+ else if (typeof value === "object" && value !== null) {
939
+ for (const item of Object.values(value))
940
+ collectBlobUris(item, uris);
941
+ }
942
+ }
943
+ async function recordExists(dataFolder, typeFolder, uri) {
944
+ const parsed = parseRecordUri(uri);
945
+ if (parsed === undefined)
946
+ return false;
947
+ if (await readValidDeclaration(typeFolder, parsed.type) === undefined)
948
+ return false;
949
+ const stored = await readStoredRecord(recordPath(dataFolder, parsed.type, parsed.key));
950
+ return stored.status === "found";
951
+ }
952
+ async function readValidDeclaration(typeFolder, type) {
953
+ const result = await readDeclaration(typeFolder, type);
954
+ if (result.status !== "found")
955
+ return undefined;
956
+ try {
957
+ await prepareSchema(result.value.schema);
958
+ }
959
+ catch (error) {
960
+ if (error instanceof SchemaRefusal)
961
+ return undefined;
962
+ throw error;
963
+ }
964
+ return result.value;
965
+ }
966
+ async function invalidRecords(dataFolder, type, schema) {
967
+ const errors = [];
968
+ const entries = await recognizedRecordEntries(join(dataFolder, type));
969
+ for (const entry of entries) {
970
+ const key = entry.name.slice(0, -".json".length);
971
+ const uri = recordUri(type, key);
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
+ }
980
+ if (record.status !== "found") {
981
+ errors.push({ uri, message: record.status === "invalid" ? record.message : "record does not exist" });
982
+ continue;
983
+ }
984
+ const validationErrors = await schema.validate(record.value.value);
985
+ if (validationErrors.length > 0)
986
+ errors.push({ uri, message: validationErrors.join("; ") });
987
+ }
988
+ return errors;
989
+ }
990
+ async function readDeclaration(folder, type) {
991
+ if (!await isDirectory(folder))
992
+ return { status: "missing" };
993
+ const result = await readJson(join(folder, `${type}.json`), "type declaration");
994
+ if (result.status !== "found")
995
+ return result;
996
+ if (!isTypeDefinition(result.value)) {
997
+ return { status: "invalid", message: "type declaration is not valid" };
998
+ }
999
+ return { status: "found", value: result.value };
1000
+ }
1001
+ async function readStoredRecord(path) {
1002
+ if (!await isDirectory(dirname(path)))
1003
+ return { status: "missing" };
1004
+ const result = await readJson(path, "record");
1005
+ if (result.status !== "found")
1006
+ return result;
1007
+ if (!isStoredRecord(result.value)) {
1008
+ return { status: "invalid", message: "record is not valid" };
1009
+ }
1010
+ return { status: "found", value: result.value };
1011
+ }
1012
+ async function readJson(path, label) {
1013
+ const bytes = await readRegularFile(path);
1014
+ if (bytes === undefined)
1015
+ return { status: "missing" };
1016
+ try {
1017
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
1018
+ return { status: "found", value: JSON.parse(text) };
1019
+ }
1020
+ catch {
1021
+ return { status: "invalid", message: `${label} is unavailable` };
1022
+ }
1023
+ }
1024
+ async function writeJsonAtomically(path, value) {
1025
+ const temporaryPath = join(dirname(path), `.${path.slice(path.lastIndexOf("/") + 1)}.${process.pid}.${randomUUID()}`);
1026
+ const text = jsonText(value);
1027
+ try {
1028
+ await writeFile(temporaryPath, text, { encoding: "utf8", flag: "wx" });
1029
+ await rename(temporaryPath, path);
1030
+ }
1031
+ catch (error) {
1032
+ try {
1033
+ await unlink(temporaryPath);
1034
+ }
1035
+ catch (cleanupError) {
1036
+ if (!hasCode(cleanupError, "ENOENT"))
1037
+ throw cleanupError;
1038
+ }
1039
+ throw error;
1040
+ }
1041
+ }
1042
+ function mergePatch(target, patch) {
1043
+ const result = { ...target };
1044
+ for (const [key, patchValue] of Object.entries(patch)) {
1045
+ if (patchValue === null) {
1046
+ delete result[key];
1047
+ }
1048
+ else if (isJsonObject(patchValue)) {
1049
+ Object.defineProperty(result, key, {
1050
+ value: mergePatch(isJsonObject(result[key]) ? result[key] : {}, patchValue),
1051
+ enumerable: true,
1052
+ configurable: true,
1053
+ writable: true,
1054
+ });
1055
+ }
1056
+ else {
1057
+ Object.defineProperty(result, key, {
1058
+ value: patchValue,
1059
+ enumerable: true,
1060
+ configurable: true,
1061
+ writable: true,
1062
+ });
1063
+ }
1064
+ }
1065
+ return result;
1066
+ }
1067
+ function nextTid() {
1068
+ const now = BigInt(Date.now()) * 1000n;
1069
+ lastTidTimestamp = now > lastTidTimestamp ? now : lastTidTimestamp + 1n;
1070
+ let value = (lastTidTimestamp << 10n) | clockIdentifier;
1071
+ let tid = "";
1072
+ for (let index = 0; index < 13; index += 1) {
1073
+ tid = `${tidAlphabet[Number(value & 31n)]}${tid}`;
1074
+ value >>= 5n;
1075
+ }
1076
+ return tid;
1077
+ }
1078
+ function parseRecordUri(uri) {
1079
+ if (typeof uri !== "string")
1080
+ return undefined;
1081
+ const match = recordUriPattern.exec(uri);
1082
+ if (match === null || match[1] === undefined || match[2] === undefined)
1083
+ return undefined;
1084
+ return { type: match[1], key: match[2] };
1085
+ }
1086
+ function recordUri(type, key) {
1087
+ return `autofile:///${type}/${key}`;
1088
+ }
1089
+ function typeUri(type) {
1090
+ return `autofile:///_types/${type}`;
1091
+ }
1092
+ function mimeTypeForBlob(name) {
1093
+ return lookupMimeType(name) || "application/octet-stream";
1094
+ }
1095
+ function recordPath(dataFolder, type, key) {
1096
+ return join(dataFolder, type, `${key}.json`);
1097
+ }
1098
+ function encodeRecordCursor(uri) {
1099
+ return Buffer.from(uri, "utf8").toString("base64url");
1100
+ }
1101
+ function decodeRecordCursor(cursor, types) {
1102
+ if (!/^[A-Za-z0-9_-]+$/u.test(cursor))
1103
+ return undefined;
1104
+ const bytes = Buffer.from(cursor, "base64url");
1105
+ if (bytes.toString("base64url") !== cursor)
1106
+ return undefined;
1107
+ let uri;
1108
+ try {
1109
+ uri = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1110
+ }
1111
+ catch {
1112
+ return undefined;
1113
+ }
1114
+ const record = parseRecordUri(uri);
1115
+ return record !== undefined && (types === undefined || types.includes(record.type)) ? uri : undefined;
1116
+ }
1117
+ function findWhereRefusal(value) {
1118
+ if (Array.isArray(value)) {
1119
+ for (const item of value) {
1120
+ const refusal = findWhereRefusal(item);
1121
+ if (refusal !== undefined)
1122
+ return refusal;
1123
+ }
1124
+ return undefined;
1125
+ }
1126
+ if (!isJsonObject(value))
1127
+ return undefined;
1128
+ for (const [key, item] of Object.entries(value)) {
1129
+ if (key.startsWith("$"))
1130
+ return `where: the operator ${key} is not supported`;
1131
+ const unsafeKey = key.split(".").find((part) => (part === "__proto__" || part === "constructor" || part === "prototype"));
1132
+ if (unsafeKey !== undefined)
1133
+ return `where: the key ${unsafeKey} is not supported`;
1134
+ const refusal = findWhereRefusal(item);
1135
+ if (refusal !== undefined)
1136
+ return refusal;
1137
+ }
1138
+ return undefined;
1139
+ }
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
+ }
1157
+ }
1158
+ function stringValuesContainWords(value, words) {
1159
+ if (words.length === 0)
1160
+ return true;
1161
+ const strings = [];
1162
+ collectStringValues(value, strings);
1163
+ const normalized = strings.map((item) => item.toLowerCase());
1164
+ return words.every((word) => normalized.some((item) => item.includes(word)));
1165
+ }
1166
+ function collectStringValues(value, strings) {
1167
+ if (typeof value === "string") {
1168
+ strings.push(value);
1169
+ }
1170
+ else if (Array.isArray(value)) {
1171
+ for (const item of value)
1172
+ collectStringValues(item, strings);
1173
+ }
1174
+ else if (isJsonObject(value)) {
1175
+ for (const item of Object.values(value))
1176
+ collectStringValues(item, strings);
1177
+ }
1178
+ }
1179
+ function containsStringValue(value, expected) {
1180
+ if (typeof value === "string")
1181
+ return value === expected;
1182
+ if (Array.isArray(value))
1183
+ return value.some((item) => containsStringValue(item, expected));
1184
+ return isJsonObject(value)
1185
+ && Object.values(value).some((item) => containsStringValue(item, expected));
1186
+ }
1187
+ function removeFields(value, pointers) {
1188
+ const result = structuredClone(value);
1189
+ const paths = [...new Set(pointers)].map(parseJsonPointer);
1190
+ paths.sort((left, right) => {
1191
+ const leftParent = left.slice(0, -1).join("\0");
1192
+ const rightParent = right.slice(0, -1).join("\0");
1193
+ if (leftParent === rightParent) {
1194
+ const leftIndex = Number(left.at(-1));
1195
+ const rightIndex = Number(right.at(-1));
1196
+ if (Number.isInteger(leftIndex) && Number.isInteger(rightIndex)) {
1197
+ return rightIndex - leftIndex;
1198
+ }
1199
+ }
1200
+ return right.length - left.length;
1201
+ });
1202
+ for (const path of paths) {
1203
+ const field = path.at(-1);
1204
+ if (field === undefined)
1205
+ continue;
1206
+ let parent = result;
1207
+ let parentFound = true;
1208
+ for (const part of path.slice(0, -1)) {
1209
+ let next;
1210
+ if (Array.isArray(parent))
1211
+ next = parent[Number(part)];
1212
+ else if (isJsonObject(parent))
1213
+ next = parent[part];
1214
+ if (next === undefined) {
1215
+ parentFound = false;
1216
+ break;
1217
+ }
1218
+ parent = next;
1219
+ }
1220
+ if (!parentFound)
1221
+ continue;
1222
+ if (Array.isArray(parent))
1223
+ parent.splice(Number(field), 1);
1224
+ else if (isJsonObject(parent))
1225
+ delete parent[field];
1226
+ }
1227
+ return result;
1228
+ }
1229
+ function parseJsonPointer(pointer) {
1230
+ if (pointer === "")
1231
+ return [];
1232
+ return pointer.slice(1).split("/").map((part) => (part.replaceAll("~1", "/").replaceAll("~0", "~")));
1233
+ }
1234
+ function timestampAfter(previous) {
1235
+ return new Date(Math.max(Date.now(), new Date(previous).valueOf() + 1)).toISOString();
1236
+ }
1237
+ function refuse(message, uri) {
1238
+ throw new AutofileError([{ ...(uri === undefined ? {} : { uri }), message }]);
1239
+ }
1240
+ function declarationRefusal(type, result) {
1241
+ const uri = typeUri(type);
1242
+ if (result.status === "missing")
1243
+ return refuse("type does not exist", uri);
1244
+ if (result.status === "invalid")
1245
+ return refuse(result.message, uri);
1246
+ throw new Error("A found declaration is not a refusal");
1247
+ }
1248
+ function recordReadRefusal(uri, result) {
1249
+ if (result.status === "missing")
1250
+ return refuse("record does not exist", uri);
1251
+ if (result.status === "invalid")
1252
+ return refuse(result.message, uri);
1253
+ throw new Error("A found record is not a refusal");
1254
+ }
1255
+ async function recognizedRecordEntries(folder) {
1256
+ return (await readDirectory(folder))
1257
+ .filter((entry) => {
1258
+ if (!entry.isFile() || !entry.name.endsWith(".json"))
1259
+ return false;
1260
+ return keyPattern.test(entry.name.slice(0, -".json".length));
1261
+ })
1262
+ .sort((left, right) => left.name.localeCompare(right.name));
1263
+ }
1264
+ async function readDirectory(path) {
1265
+ try {
1266
+ if (!(await lstat(path)).isDirectory())
1267
+ return [];
1268
+ return await readdir(path, { withFileTypes: true });
1269
+ }
1270
+ catch (error) {
1271
+ if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR"))
1272
+ return [];
1273
+ throw error;
1274
+ }
1275
+ }
1276
+ async function isDirectory(path) {
1277
+ try {
1278
+ return (await lstat(path)).isDirectory();
1279
+ }
1280
+ catch (error) {
1281
+ if (hasCode(error, "ENOENT") || hasCode(error, "ENOTDIR"))
1282
+ return false;
1283
+ throw error;
1284
+ }
1285
+ }
1286
+ async function isRegularFile(path) {
1287
+ try {
1288
+ return (await lstat(path)).isFile();
1289
+ }
1290
+ catch (error) {
1291
+ if (hasCode(error, "ENOENT"))
1292
+ return false;
1293
+ throw error;
1294
+ }
1295
+ }
1296
+ async function openRegularFile(path) {
1297
+ let handle;
1298
+ try {
1299
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
1300
+ }
1301
+ catch (error) {
1302
+ if (hasCode(error, "ENOENT")
1303
+ || hasCode(error, "ENOTDIR")
1304
+ || hasCode(error, "ELOOP")
1305
+ || hasCode(error, "ENXIO")
1306
+ || hasCode(error, "ENODEV")) {
1307
+ return undefined;
1308
+ }
1309
+ throw error;
1310
+ }
1311
+ try {
1312
+ const details = await handle.stat();
1313
+ if (!details.isFile()) {
1314
+ await handle.close();
1315
+ return undefined;
1316
+ }
1317
+ return { handle, size: details.size };
1318
+ }
1319
+ catch (error) {
1320
+ await handle.close();
1321
+ throw error;
1322
+ }
1323
+ }
1324
+ async function readRegularFile(path, maximumBytes = Number.POSITIVE_INFINITY) {
1325
+ const file = await openRegularFile(path);
1326
+ if (file === undefined)
1327
+ return undefined;
1328
+ try {
1329
+ if (file.size > maximumBytes)
1330
+ return undefined;
1331
+ const bytes = await file.handle.readFile();
1332
+ return bytes.byteLength <= maximumBytes ? bytes : undefined;
1333
+ }
1334
+ finally {
1335
+ await file.handle.close();
1336
+ }
1337
+ }
1338
+ async function pathExists(path) {
1339
+ try {
1340
+ await lstat(path);
1341
+ return true;
1342
+ }
1343
+ catch (error) {
1344
+ if (hasCode(error, "ENOENT"))
1345
+ return false;
1346
+ throw error;
1347
+ }
1348
+ }
1349
+ function isTypeDefinition(value) {
1350
+ if (!isJsonObject(value))
1351
+ return false;
1352
+ if (Object.keys(value).some((key) => !["description", "title", "titleField", "schema"].includes(key))) {
1353
+ return false;
1354
+ }
1355
+ if (typeof value.description !== "string" || !isJsonObject(value.schema))
1356
+ return false;
1357
+ if (value.title !== undefined && (typeof value.title !== "string" || value.title.length === 0)) {
1358
+ return false;
1359
+ }
1360
+ return value.titleField === undefined || typeof value.titleField === "string";
1361
+ }
1362
+ function isStoredRecord(value) {
1363
+ if (!isJsonObject(value))
1364
+ return false;
1365
+ if (Object.keys(value).length !== 3)
1366
+ return false;
1367
+ if (!["createdAt", "updatedAt", "value"].every((key) => Object.hasOwn(value, key)))
1368
+ return false;
1369
+ return isTimestamp(value.createdAt) && isTimestamp(value.updatedAt) && isJsonObject(value.value);
1370
+ }
1371
+ function isTimestamp(value) {
1372
+ if (typeof value !== "string" || !timestampPattern.test(value))
1373
+ return false;
1374
+ const parsed = new Date(value);
1375
+ if (Number.isNaN(parsed.valueOf()))
1376
+ return false;
1377
+ const canonical = parsed.toISOString();
1378
+ return value === canonical || value === canonical.replace(".000Z", "Z");
1379
+ }
1380
+ function isJsonObject(value) {
1381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1382
+ }
1383
+ function hasCode(error, code) {
1384
+ return hasErrorCode(error) && error.code === code;
1385
+ }
1386
+ function hasErrorCode(error) {
1387
+ return error instanceof Error && "code" in error && typeof error.code === "string";
1388
+ }
1389
+ //# sourceMappingURL=store.js.map