@texuli/core 0.7.0 → 0.8.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.
Files changed (42) hide show
  1. package/README.md +11 -5
  2. package/dist/data-source/collection.d.ts +67 -0
  3. package/dist/data-source/collection.js +247 -0
  4. package/dist/data-source/errors.d.ts +58 -0
  5. package/dist/data-source/errors.js +138 -0
  6. package/dist/data-source/index.d.ts +17 -0
  7. package/dist/data-source/index.js +17 -0
  8. package/dist/data-source/loader.d.ts +120 -0
  9. package/dist/data-source/loader.js +571 -0
  10. package/dist/data-source/query.d.ts +78 -0
  11. package/dist/data-source/query.js +339 -0
  12. package/dist/data-source/registry.d.ts +83 -0
  13. package/dist/data-source/registry.js +63 -0
  14. package/dist/data-source/schema.d.ts +122 -0
  15. package/dist/data-source/schema.js +197 -0
  16. package/dist/expression/analyze.d.ts +17 -0
  17. package/dist/expression/analyze.js +151 -0
  18. package/dist/expression/evaluate.d.ts +55 -0
  19. package/dist/expression/evaluate.js +365 -0
  20. package/dist/expression/functions.d.ts +7 -0
  21. package/dist/expression/functions.js +374 -0
  22. package/dist/expression/index.d.ts +7 -0
  23. package/dist/expression/index.js +12 -0
  24. package/dist/expression/parse.d.ts +19 -0
  25. package/dist/expression/parse.js +284 -0
  26. package/dist/expression/text.d.ts +15 -0
  27. package/dist/expression/text.js +489 -0
  28. package/dist/expression/types.d.ts +197 -0
  29. package/dist/expression/types.js +35 -0
  30. package/dist/expression/values.d.ts +14 -0
  31. package/dist/expression/values.js +68 -0
  32. package/dist/i18n/catalog.d.ts +51 -0
  33. package/dist/i18n/catalog.js +109 -0
  34. package/dist/i18n/format.d.ts +41 -0
  35. package/dist/i18n/format.js +117 -0
  36. package/dist/i18n/i18n.d.ts +70 -0
  37. package/dist/i18n/i18n.js +185 -0
  38. package/dist/i18n/index.d.ts +8 -0
  39. package/dist/i18n/index.js +8 -0
  40. package/dist/i18n/pseudo.d.ts +4 -0
  41. package/dist/i18n/pseudo.js +41 -0
  42. package/package.json +4 -1
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The source loader: every read and write a view makes goes through it.
3
+ * It validates the query against the source's declaration, deduplicates
4
+ * identical reads in flight, cancels through `AbortSignal` (the shared
5
+ * request aborts only when every caller has), retries retryable read
6
+ * failures with exponential backoff, caches pages by query key with
7
+ * invalidation, and checks every response before a view sees it. Time
8
+ * comes from an injectable clock, so tests never wait.
9
+ *
10
+ * Methods never throw and never reject: they resolve a `SourceResult`.
11
+ */
12
+ import { type SourceError } from './errors.js';
13
+ import { type SourceQuery } from './query.js';
14
+ import type { SourceRegistry, SourceRow, SourceRowId } from './registry.js';
15
+ import { type SourceDescriptor } from './schema.js';
16
+ /** Time for retries, timeouts and cache freshness. Inject a fake one in tests. */
17
+ export interface SourceClock {
18
+ now(): number;
19
+ setTimeout(callback: () => void, ms: number): unknown;
20
+ clearTimeout(handle: unknown): void;
21
+ }
22
+ /** The platform clock (`Date.now` and the global timers), read when called. */
23
+ export declare const SYSTEM_SOURCE_CLOCK: SourceClock;
24
+ export interface SourceRetryPolicy {
25
+ /** Retries after the first attempt. */
26
+ readonly retries: number;
27
+ /** The first backoff; each retry doubles it. */
28
+ readonly baseDelayMs: number;
29
+ readonly maxDelayMs: number;
30
+ }
31
+ export declare const DEFAULT_SOURCE_RETRY: SourceRetryPolicy;
32
+ /** The longest server retry hint the loader honors. */
33
+ export declare const MAX_RETRY_AFTER_MS = 60000;
34
+ export interface SourceLoaderOptions {
35
+ registry: SourceRegistry;
36
+ clock?: SourceClock;
37
+ /** Read retries; `false` disables them. Writes are never retried automatically. */
38
+ retry?: Partial<SourceRetryPolicy> | false;
39
+ /** One attempt's time limit (a retryable `timeout`); `0` disables it. Default 15,000. */
40
+ timeoutMs?: number;
41
+ /** The page cache; `false` disables it. Default 64 entries, fresh for 30 seconds. */
42
+ cache?: {
43
+ maxEntries?: number;
44
+ staleMs?: number;
45
+ } | false;
46
+ }
47
+ export type SourceResult<T> = {
48
+ readonly ok: true;
49
+ readonly value: T;
50
+ } | {
51
+ readonly ok: false;
52
+ readonly error: SourceError;
53
+ };
54
+ /** One loaded page, checked. */
55
+ export interface SourceListResult {
56
+ readonly rows: readonly SourceRow[];
57
+ readonly pageSize: number;
58
+ readonly hasMore: boolean;
59
+ /** `cursor` sources: where the next page starts. */
60
+ readonly nextCursor: string | null;
61
+ /** `offset` sources: where the next page starts. */
62
+ readonly nextOffset: number | null;
63
+ readonly total: number | null;
64
+ }
65
+ export interface SourceCallOptions {
66
+ readonly signal?: AbortSignal;
67
+ }
68
+ export interface SourceListOptions extends SourceCallOptions {
69
+ /** A `cursor` source's page (from `nextCursor`). */
70
+ readonly cursor?: string | null;
71
+ /** An `offset` source's page (from `nextOffset`). */
72
+ readonly offset?: number | null;
73
+ /** Skip a fresh cache entry and ask the source. */
74
+ readonly fresh?: boolean;
75
+ }
76
+ export interface SourceMutationOptions extends SourceCallOptions {
77
+ /** Passed to subscribers with the invalidation this write causes, so its own view can skip a reload. */
78
+ readonly origin?: unknown;
79
+ }
80
+ export interface SourceInvalidation {
81
+ readonly source: string;
82
+ readonly origin?: unknown;
83
+ }
84
+ export type SourceQueryResolution = {
85
+ readonly ok: true;
86
+ readonly query: SourceQuery;
87
+ readonly descriptor: SourceDescriptor;
88
+ } | {
89
+ readonly ok: false;
90
+ readonly error: SourceError;
91
+ };
92
+ export interface SourceLoader {
93
+ readonly registry: SourceRegistry;
94
+ /** Resolve a source name and validate a query, without loading. */
95
+ checkQuery(name: string, query: unknown): SourceQueryResolution;
96
+ list(name: string, query: unknown, options?: SourceListOptions): Promise<SourceResult<SourceListResult>>;
97
+ /** A fresh cached page, synchronously, or `undefined`. */
98
+ peek(name: string, query: unknown, options?: SourceListOptions): SourceListResult | undefined;
99
+ get(name: string, id: SourceRowId, options?: SourceCallOptions): Promise<SourceResult<SourceRow | null>>;
100
+ create(name: string, values: unknown, options?: SourceMutationOptions): Promise<SourceResult<SourceRow>>;
101
+ update(name: string, id: SourceRowId, changes: unknown, options?: SourceMutationOptions): Promise<SourceResult<SourceRow>>;
102
+ remove(name: string, id: SourceRowId, options?: SourceMutationOptions): Promise<SourceResult<null>>;
103
+ /** Drop cached pages of one source (or all) and tell its subscribers. */
104
+ invalidate(name?: string, origin?: unknown): void;
105
+ /** Hear invalidations of one source. Returns the unsubscribe. */
106
+ subscribe(name: string, listener: (event: SourceInvalidation) => void): () => void;
107
+ /** Shared reads in flight that a new call can join (an invalidation detaches them). */
108
+ inFlight(): number;
109
+ }
110
+ /** Whether `id` is a usable row id. */
111
+ export declare function isSourceRowId(id: unknown): id is SourceRowId;
112
+ /**
113
+ * Read one row from a response: a plain object with a usable key, keeping
114
+ * the key and the projected (or every declared) field. A value that does
115
+ * not fit its field's type becomes `null`; undeclared fields are dropped.
116
+ * `null` when the row itself is unusable.
117
+ */
118
+ export declare function readSourceRow(descriptor: SourceDescriptor, input: unknown, fields?: readonly string[]): SourceRow | null;
119
+ /** Create a loader over a registry. */
120
+ export declare function createSourceLoader(options: SourceLoaderOptions): SourceLoader;
@@ -0,0 +1,571 @@
1
+ /**
2
+ * The source loader: every read and write a view makes goes through it.
3
+ * It validates the query against the source's declaration, deduplicates
4
+ * identical reads in flight, cancels through `AbortSignal` (the shared
5
+ * request aborts only when every caller has), retries retryable read
6
+ * failures with exponential backoff, caches pages by query key with
7
+ * invalidation, and checks every response before a view sees it. Time
8
+ * comes from an injectable clock, so tests never wait.
9
+ *
10
+ * Methods never throw and never reject: they resolve a `SourceResult`.
11
+ */
12
+ import { sourceError, toSourceError } from "./errors.js";
13
+ import { checkSourceQuery, fitsSourceField, sourceQueryKey, } from "./query.js";
14
+ import { SOURCE_LIMITS, sourceField, } from "./schema.js";
15
+ /** The platform clock (`Date.now` and the global timers), read when called. */
16
+ export const SYSTEM_SOURCE_CLOCK = Object.freeze({
17
+ now: () => Date.now(),
18
+ setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),
19
+ clearTimeout: (handle) => globalThis.clearTimeout(handle),
20
+ });
21
+ export const DEFAULT_SOURCE_RETRY = Object.freeze({
22
+ retries: 2,
23
+ baseDelayMs: 300,
24
+ maxDelayMs: 5000,
25
+ });
26
+ /** The longest server retry hint the loader honors. */
27
+ export const MAX_RETRY_AFTER_MS = 60_000;
28
+ function isBag(value) {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
30
+ }
31
+ function failure(error) {
32
+ return Object.freeze({ ok: false, error });
33
+ }
34
+ function success(value) {
35
+ return Object.freeze({ ok: true, value });
36
+ }
37
+ /** Whether `id` is a usable row id. */
38
+ export function isSourceRowId(id) {
39
+ return ((typeof id === 'string' && id.length > 0 && id.length <= 256) ||
40
+ (typeof id === 'number' && Number.isFinite(id)));
41
+ }
42
+ /**
43
+ * Read one row from a response: a plain object with a usable key, keeping
44
+ * the key and the projected (or every declared) field. A value that does
45
+ * not fit its field's type becomes `null`; undeclared fields are dropped.
46
+ * `null` when the row itself is unusable.
47
+ */
48
+ export function readSourceRow(descriptor, input, fields) {
49
+ if (!isBag(input))
50
+ return null;
51
+ const id = Object.hasOwn(input, descriptor.key) ? input[descriptor.key] : undefined;
52
+ if (!isSourceRowId(id))
53
+ return null;
54
+ const row = {};
55
+ const names = fields ?? descriptor.fields.map((field) => field.name);
56
+ for (const field of descriptor.fields) {
57
+ if (field.name !== descriptor.key && !names.includes(field.name))
58
+ continue;
59
+ const value = Object.hasOwn(input, field.name) ? input[field.name] : null;
60
+ // The key was checked above; it keeps its value even when its type differs (a number id in a text key).
61
+ Object.defineProperty(row, field.name, {
62
+ value: field.name === descriptor.key ? id : fitsSourceField(field, value) ? value : null,
63
+ enumerable: true,
64
+ });
65
+ }
66
+ return Object.freeze(row);
67
+ }
68
+ function issue(code, path, message) {
69
+ return Object.freeze({ code, path, message });
70
+ }
71
+ /** Check create or update values against the declaration: editable, fitting, required. */
72
+ function checkValues(descriptor, input, mode) {
73
+ const issues = [];
74
+ if (!isBag(input))
75
+ return { ok: false, issues: [issue('shape', '$', 'Values are an object.')] };
76
+ const keys = Object.keys(input);
77
+ if (keys.length > SOURCE_LIMITS.changes || (mode === 'update' && keys.length === 0)) {
78
+ return {
79
+ ok: false,
80
+ issues: [issue('shape', '$', `Change 1 to ${SOURCE_LIMITS.changes} fields.`)],
81
+ };
82
+ }
83
+ const values = {};
84
+ for (const key of keys) {
85
+ const field = sourceField(descriptor, key);
86
+ const path = `$.${key}`;
87
+ if (!field)
88
+ issues.push(issue('unknown-field', path, `"${descriptor.name}" has no "${key}" field.`));
89
+ else if (!field.editable)
90
+ issues.push(issue('value', path, `"${key}" cannot be changed.`));
91
+ else if (!fitsSourceField(field, input[key]))
92
+ issues.push(issue('value', path, `The value does not fit "${key}" (${field.type}).`));
93
+ else
94
+ Object.defineProperty(values, key, { value: input[key], enumerable: true });
95
+ }
96
+ if (mode === 'create') {
97
+ for (const field of descriptor.fields) {
98
+ if (field.required && (!Object.hasOwn(values, field.name) || values[field.name] === null)) {
99
+ issues.push(issue('value', `$.${field.name}`, `"${field.name}" is required.`));
100
+ }
101
+ }
102
+ }
103
+ return issues.length > 0 ? { ok: false, issues } : { ok: true, values: Object.freeze(values) };
104
+ }
105
+ /** Create a loader over a registry. */
106
+ export function createSourceLoader(options) {
107
+ const { registry } = options;
108
+ const clock = options.clock ?? SYSTEM_SOURCE_CLOCK;
109
+ const retry = options.retry === false
110
+ ? { retries: 0, baseDelayMs: 0, maxDelayMs: 0 }
111
+ : { ...DEFAULT_SOURCE_RETRY, ...(options.retry ?? {}) };
112
+ const timeoutMs = options.timeoutMs ?? 15_000;
113
+ const cacheOptions = options.cache === false ? null : (options.cache ?? {});
114
+ const maxEntries = cacheOptions ? Math.max(0, cacheOptions.maxEntries ?? 64) : 0;
115
+ const staleMs = cacheOptions ? Math.max(0, cacheOptions.staleMs ?? 30_000) : 0;
116
+ const inflight = new Map();
117
+ const cache = new Map();
118
+ const generations = new Map();
119
+ const listeners = new Map();
120
+ function source(name, op) {
121
+ const found = registry.get(name);
122
+ if (!found) {
123
+ return failure(sourceError('unknown-source', {
124
+ source: String(name).slice(0, 64),
125
+ message: 'No such source.',
126
+ }));
127
+ }
128
+ if (!found.descriptor.operations.includes(op)) {
129
+ return failure(sourceError('unsupported', { source: name, message: `The source does not ${op}.` }));
130
+ }
131
+ return success(found);
132
+ }
133
+ function sleep(ms, signal) {
134
+ return new Promise((resolve) => {
135
+ if (signal.aborted) {
136
+ resolve(false);
137
+ return;
138
+ }
139
+ const onAbort = () => {
140
+ clock.clearTimeout(handle);
141
+ resolve(false);
142
+ };
143
+ const handle = clock.setTimeout(() => {
144
+ signal.removeEventListener('abort', onAbort);
145
+ resolve(true);
146
+ }, ms);
147
+ signal.addEventListener('abort', onAbort, { once: true });
148
+ });
149
+ }
150
+ /** One attempt: the adapter call raced against the attempt's timeout and the caller's abort. */
151
+ async function once(name, outer, call) {
152
+ if (outer.aborted)
153
+ return failure(sourceError('aborted', { source: name }));
154
+ const controller = new AbortController();
155
+ let timer;
156
+ let settleGuard = () => { };
157
+ const guard = new Promise((resolve) => {
158
+ settleGuard = resolve;
159
+ });
160
+ const forward = () => {
161
+ controller.abort();
162
+ settleGuard(failure(sourceError('aborted', { source: name })));
163
+ };
164
+ outer.addEventListener('abort', forward, { once: true });
165
+ if (timeoutMs > 0) {
166
+ timer = clock.setTimeout(() => {
167
+ controller.abort();
168
+ settleGuard(failure(sourceError('timeout', { source: name })));
169
+ }, timeoutMs);
170
+ }
171
+ const attempt = Promise.resolve()
172
+ .then(() => call(controller.signal))
173
+ .then((value) => success(value), (thrown) => failure(toSourceError(thrown, name)));
174
+ try {
175
+ return await Promise.race([attempt, guard]);
176
+ }
177
+ finally {
178
+ if (timer !== undefined)
179
+ clock.clearTimeout(timer);
180
+ outer.removeEventListener('abort', forward);
181
+ }
182
+ }
183
+ /** Attempts with backoff while the failure is retryable and retries remain. */
184
+ async function withRetries(name, signal, retries, call) {
185
+ for (let attempt = 0;; attempt += 1) {
186
+ const result = await once(name, signal, call);
187
+ if (result.ok || !result.error.retryable || attempt >= retries || signal.aborted) {
188
+ return result;
189
+ }
190
+ const backoff = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** attempt);
191
+ const hint = result.error.retryAfterMs;
192
+ const delay = hint === undefined ? backoff : Math.min(hint, MAX_RETRY_AFTER_MS);
193
+ if (!(await sleep(delay, signal)))
194
+ return failure(sourceError('aborted', { source: name }));
195
+ }
196
+ }
197
+ /** Join a shared read; this caller's abort leaves it, and the last one out cancels it. */
198
+ function join(entry, signal, name, drop) {
199
+ if (signal?.aborted)
200
+ return Promise.resolve(failure(sourceError('aborted', { source: name })));
201
+ entry.waiters += 1;
202
+ if (!signal)
203
+ return entry.promise;
204
+ return new Promise((resolve) => {
205
+ let done = false;
206
+ const onAbort = () => {
207
+ if (done)
208
+ return;
209
+ done = true;
210
+ resolve(failure(sourceError('aborted', { source: name })));
211
+ entry.waiters -= 1;
212
+ if (entry.waiters === 0) {
213
+ drop();
214
+ entry.controller.abort();
215
+ }
216
+ };
217
+ signal.addEventListener('abort', onAbort, { once: true });
218
+ void entry.promise.then((result) => {
219
+ if (done)
220
+ return;
221
+ done = true;
222
+ signal.removeEventListener('abort', onAbort);
223
+ resolve(result);
224
+ });
225
+ });
226
+ }
227
+ function cacheGet(key) {
228
+ const entry = cache.get(key);
229
+ if (!entry)
230
+ return undefined;
231
+ if (clock.now() - entry.at >= staleMs) {
232
+ cache.delete(key);
233
+ return undefined;
234
+ }
235
+ cache.delete(key);
236
+ cache.set(key, entry);
237
+ return entry.value;
238
+ }
239
+ function cacheSet(key, name, value) {
240
+ if (maxEntries === 0 || staleMs === 0)
241
+ return;
242
+ cache.delete(key);
243
+ cache.set(key, { source: name, value, at: clock.now() });
244
+ while (cache.size > maxEntries) {
245
+ const oldest = cache.keys().next().value;
246
+ if (oldest === undefined)
247
+ break;
248
+ cache.delete(oldest);
249
+ }
250
+ }
251
+ function checkQuery(name, query) {
252
+ const found = registry.get(name);
253
+ if (!found) {
254
+ return {
255
+ ok: false,
256
+ error: sourceError('unknown-source', {
257
+ source: String(name).slice(0, 64),
258
+ message: 'No such source.',
259
+ }),
260
+ };
261
+ }
262
+ const checked = checkSourceQuery(found.descriptor, query);
263
+ if (!checked.ok) {
264
+ const code = checked.issues.some((i) => i.code === 'operation')
265
+ ? 'unsupported'
266
+ : 'invalid-query';
267
+ return {
268
+ ok: false,
269
+ error: sourceError(code, {
270
+ source: name,
271
+ issues: checked.issues,
272
+ message: 'The query was refused.',
273
+ }),
274
+ };
275
+ }
276
+ return Object.freeze({ ok: true, query: checked.query, descriptor: found.descriptor });
277
+ }
278
+ function resolvePage(name, query, options) {
279
+ const resolved = checkQuery(name, query);
280
+ if (!resolved.ok)
281
+ return resolved;
282
+ const { descriptor } = resolved;
283
+ const size = resolved.query.pageSize ?? descriptor.defaultPageSize;
284
+ const cursor = options.cursor ?? undefined;
285
+ const offset = options.offset ?? undefined;
286
+ const bad = (message) => ({
287
+ ok: false,
288
+ error: sourceError('invalid-query', { source: name, message }),
289
+ });
290
+ if (cursor !== undefined) {
291
+ if (descriptor.pagination !== 'cursor')
292
+ return bad('This source does not page by cursor.');
293
+ if (typeof cursor !== 'string' ||
294
+ cursor === '' ||
295
+ cursor.length > SOURCE_LIMITS.cursorLength) {
296
+ return bad('The cursor is malformed.');
297
+ }
298
+ }
299
+ if (offset !== undefined) {
300
+ if (descriptor.pagination !== 'offset')
301
+ return bad('This source does not page by offset.');
302
+ if (!Number.isInteger(offset) || offset < 0)
303
+ return bad('The offset is malformed.');
304
+ }
305
+ const key = sourceQueryKey(name, resolved.query, {
306
+ cursor: cursor ?? null,
307
+ offset: offset ?? null,
308
+ });
309
+ return {
310
+ ok: true,
311
+ key,
312
+ resolution: resolved,
313
+ size,
314
+ ...(cursor === undefined ? {} : { cursor }),
315
+ ...(offset === undefined ? {} : { offset }),
316
+ };
317
+ }
318
+ function readPage(descriptor, query, size, offset, input) {
319
+ const invalid = (message) => failure(sourceError('invalid-response', { source: descriptor.name, message }));
320
+ if (!isBag(input) || !Array.isArray(input.rows))
321
+ return invalid('A page has a rows array.');
322
+ if (input.rows.length > size)
323
+ return invalid(`The page holds more than ${size} rows.`);
324
+ const rows = [];
325
+ const seen = new Set();
326
+ for (const raw of input.rows) {
327
+ const row = readSourceRow(descriptor, raw, query.fields);
328
+ if (!row)
329
+ return invalid('A row is not an object with a usable key.');
330
+ const id = `${typeof row[descriptor.key]}:${String(row[descriptor.key])}`;
331
+ if (seen.has(id))
332
+ return invalid('Two rows share a key.');
333
+ seen.add(id);
334
+ rows.push(row);
335
+ }
336
+ let nextCursor = null;
337
+ let nextOffset = null;
338
+ let hasMore = false;
339
+ if (descriptor.pagination === 'cursor') {
340
+ const next = input.nextCursor;
341
+ if (next !== undefined && next !== null) {
342
+ if (typeof next !== 'string' || next === '' || next.length > SOURCE_LIMITS.cursorLength) {
343
+ return invalid('nextCursor is malformed.');
344
+ }
345
+ nextCursor = next;
346
+ hasMore = true;
347
+ }
348
+ }
349
+ else if (descriptor.pagination === 'offset') {
350
+ hasMore = typeof input.hasMore === 'boolean' ? input.hasMore : rows.length === size;
351
+ if (rows.length === 0)
352
+ hasMore = false;
353
+ if (hasMore)
354
+ nextOffset = (offset ?? 0) + rows.length;
355
+ }
356
+ const total = typeof input.total === 'number' && Number.isInteger(input.total) && input.total >= 0
357
+ ? input.total
358
+ : null;
359
+ return success(Object.freeze({
360
+ rows: Object.freeze(rows),
361
+ pageSize: size,
362
+ hasMore,
363
+ nextCursor,
364
+ nextOffset,
365
+ total,
366
+ }));
367
+ }
368
+ function list(name, query, options = {}) {
369
+ const page = resolvePage(name, query, options);
370
+ if (!page.ok)
371
+ return Promise.resolve(failure(page.error));
372
+ if (!page.resolution.descriptor.operations.includes('list') &&
373
+ page.resolution.query.search === undefined) {
374
+ // A search-only source lists through search text.
375
+ return Promise.resolve(failure(sourceError('unsupported', {
376
+ source: name,
377
+ message: 'This source lists only with search text.',
378
+ })));
379
+ }
380
+ const { key, size } = page;
381
+ // A caller that already left starts nothing.
382
+ if (options.signal?.aborted) {
383
+ return Promise.resolve(failure(sourceError('aborted', { source: name })));
384
+ }
385
+ if (!options.fresh) {
386
+ const hit = cacheGet(key);
387
+ if (hit)
388
+ return Promise.resolve(success(hit));
389
+ }
390
+ let entry = inflight.get(key);
391
+ if (!entry) {
392
+ const found = registry.get(name);
393
+ const adapter = found.adapter;
394
+ const { descriptor, query: checkedQuery } = page.resolution;
395
+ const generation = generations.get(name) ?? 0;
396
+ const controller = new AbortController();
397
+ const request = {
398
+ size,
399
+ ...(page.cursor === undefined ? {} : { cursor: page.cursor }),
400
+ ...(page.offset === undefined ? {} : { offset: page.offset }),
401
+ };
402
+ const promise = withRetries(name, controller.signal, retry.retries, (signal) => adapter.list({ query: checkedQuery, page: Object.freeze(request), signal })).then((result) => {
403
+ if (inflight.get(key) === created)
404
+ inflight.delete(key);
405
+ if (!result.ok)
406
+ return result;
407
+ const read = readPage(descriptor, checkedQuery, size, page.offset, result.value);
408
+ // A page that started before an invalidation is not cached.
409
+ if (read.ok && (generations.get(name) ?? 0) === generation)
410
+ cacheSet(key, name, read.value);
411
+ return read;
412
+ });
413
+ const created = { source: name, controller, promise, waiters: 0 };
414
+ entry = created;
415
+ inflight.set(key, created);
416
+ }
417
+ const shared = entry;
418
+ return join(shared, options.signal, name, () => {
419
+ if (inflight.get(key) === shared)
420
+ inflight.delete(key);
421
+ });
422
+ }
423
+ function peek(name, query, options = {}) {
424
+ const page = resolvePage(name, query, options);
425
+ return page.ok ? cacheGet(page.key) : undefined;
426
+ }
427
+ /** A single read or write: no sharing, optional retries. */
428
+ function single(name, signal, retries, call) {
429
+ if (signal?.aborted)
430
+ return Promise.resolve(failure(sourceError('aborted', { source: name })));
431
+ const controller = new AbortController();
432
+ const entry = {
433
+ controller,
434
+ promise: withRetries(name, controller.signal, retries, call),
435
+ waiters: 0,
436
+ };
437
+ return join(entry, signal, name, () => { });
438
+ }
439
+ function invalidate(name, origin) {
440
+ const names = name === undefined ? [...new Set([...generations.keys(), ...listeners.keys()])] : [name];
441
+ for (const [key, entry] of cache) {
442
+ if (name === undefined || entry.source === name)
443
+ cache.delete(key);
444
+ }
445
+ // A read that started before the invalidation may hold pre-write rows:
446
+ // detach it so a later call starts a fresh read. It is not aborted, so
447
+ // its current callers still settle (and its page is not cached).
448
+ for (const [key, entry] of inflight) {
449
+ if (name === undefined || entry.source === name)
450
+ inflight.delete(key);
451
+ }
452
+ for (const each of names) {
453
+ generations.set(each, (generations.get(each) ?? 0) + 1);
454
+ const event = Object.freeze({
455
+ source: each,
456
+ ...(origin === undefined ? {} : { origin }),
457
+ });
458
+ for (const listener of [...(listeners.get(each) ?? [])]) {
459
+ try {
460
+ listener(event);
461
+ }
462
+ catch {
463
+ // A listener's failure never stops the others.
464
+ }
465
+ }
466
+ }
467
+ }
468
+ async function write(name, op, options, call) {
469
+ const found = source(name, op);
470
+ if (!found.ok)
471
+ return failure(found.error);
472
+ // Writes are not retried: a write that may have landed must not land twice.
473
+ const result = await single(name, options.signal, 0, (signal) => call(found.value, signal).then((inner) => {
474
+ if (!inner.ok)
475
+ throw inner.error;
476
+ return inner.value;
477
+ }));
478
+ if (result.ok)
479
+ invalidate(name, options.origin);
480
+ return result;
481
+ }
482
+ const loader = {
483
+ registry,
484
+ checkQuery,
485
+ list,
486
+ peek,
487
+ get(name, id, options = {}) {
488
+ const found = source(name, 'get');
489
+ if (!found.ok)
490
+ return Promise.resolve(failure(found.error));
491
+ if (!isSourceRowId(id)) {
492
+ return Promise.resolve(failure(sourceError('invalid-input', { source: name, message: 'The id is malformed.' })));
493
+ }
494
+ const { descriptor, adapter } = found.value;
495
+ return single(name, options.signal, retry.retries, async (signal) => {
496
+ const raw = await adapter.get({ id, signal });
497
+ if (raw === null || raw === undefined)
498
+ return null;
499
+ const row = readSourceRow(descriptor, raw);
500
+ if (!row)
501
+ throw sourceError('invalid-response', { source: name, message: 'The row is unusable.' });
502
+ return row;
503
+ });
504
+ },
505
+ create(name, values, options = {}) {
506
+ return write(name, 'create', options, async (found, signal) => {
507
+ const checked = checkValues(found.descriptor, values, 'create');
508
+ if (!checked.ok) {
509
+ return failure(sourceError('invalid-input', { source: name, issues: checked.issues }));
510
+ }
511
+ const raw = await found.adapter.create({
512
+ values: checked.values,
513
+ signal,
514
+ });
515
+ const row = readSourceRow(found.descriptor, raw);
516
+ return row
517
+ ? success(row)
518
+ : failure(sourceError('invalid-response', {
519
+ source: name,
520
+ message: 'The created row is unusable.',
521
+ }));
522
+ });
523
+ },
524
+ update(name, id, changes, options = {}) {
525
+ return write(name, 'update', options, async (found, signal) => {
526
+ if (!isSourceRowId(id))
527
+ return failure(sourceError('invalid-input', { source: name, message: 'The id is malformed.' }));
528
+ const checked = checkValues(found.descriptor, changes, 'update');
529
+ if (!checked.ok) {
530
+ return failure(sourceError('invalid-input', { source: name, issues: checked.issues }));
531
+ }
532
+ const raw = await found.adapter.update({
533
+ id,
534
+ changes: checked.values,
535
+ signal,
536
+ });
537
+ const row = readSourceRow(found.descriptor, raw);
538
+ return row
539
+ ? success(row)
540
+ : failure(sourceError('invalid-response', {
541
+ source: name,
542
+ message: 'The updated row is unusable.',
543
+ }));
544
+ });
545
+ },
546
+ remove(name, id, options = {}) {
547
+ return write(name, 'delete', options, async (found, signal) => {
548
+ if (!isSourceRowId(id))
549
+ return failure(sourceError('invalid-input', { source: name, message: 'The id is malformed.' }));
550
+ await found.adapter.delete({ id, signal });
551
+ return success(null);
552
+ });
553
+ },
554
+ invalidate,
555
+ subscribe(name, listener) {
556
+ let set = listeners.get(name);
557
+ if (!set) {
558
+ set = new Set();
559
+ listeners.set(name, set);
560
+ }
561
+ set.add(listener);
562
+ return () => {
563
+ set?.delete(listener);
564
+ if (set?.size === 0)
565
+ listeners.delete(name);
566
+ };
567
+ },
568
+ inFlight: () => inflight.size,
569
+ };
570
+ return Object.freeze(loader);
571
+ }