@texuli/core 0.6.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.
- package/README.md +11 -5
- package/dist/data-source/collection.d.ts +67 -0
- package/dist/data-source/collection.js +247 -0
- package/dist/data-source/errors.d.ts +58 -0
- package/dist/data-source/errors.js +138 -0
- package/dist/data-source/index.d.ts +17 -0
- package/dist/data-source/index.js +17 -0
- package/dist/data-source/loader.d.ts +120 -0
- package/dist/data-source/loader.js +571 -0
- package/dist/data-source/query.d.ts +78 -0
- package/dist/data-source/query.js +339 -0
- package/dist/data-source/registry.d.ts +83 -0
- package/dist/data-source/registry.js +63 -0
- package/dist/data-source/schema.d.ts +122 -0
- package/dist/data-source/schema.js +197 -0
- package/dist/expression/analyze.d.ts +17 -0
- package/dist/expression/analyze.js +151 -0
- package/dist/expression/evaluate.d.ts +55 -0
- package/dist/expression/evaluate.js +365 -0
- package/dist/expression/functions.d.ts +7 -0
- package/dist/expression/functions.js +374 -0
- package/dist/expression/index.d.ts +7 -0
- package/dist/expression/index.js +12 -0
- package/dist/expression/parse.d.ts +19 -0
- package/dist/expression/parse.js +284 -0
- package/dist/expression/text.d.ts +15 -0
- package/dist/expression/text.js +489 -0
- package/dist/expression/types.d.ts +197 -0
- package/dist/expression/types.js +35 -0
- package/dist/expression/values.d.ts +14 -0
- package/dist/expression/values.js +68 -0
- package/dist/i18n/catalog.d.ts +51 -0
- package/dist/i18n/catalog.js +109 -0
- package/dist/i18n/format.d.ts +41 -0
- package/dist/i18n/format.js +117 -0
- package/dist/i18n/i18n.d.ts +70 -0
- package/dist/i18n/i18n.js +185 -0
- package/dist/i18n/index.d.ts +8 -0
- package/dist/i18n/index.js +8 -0
- package/dist/i18n/pseudo.d.ts +4 -0
- package/dist/i18n/pseudo.js +41 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ when you need Texuli's URL policy to sanitize links and media from agents
|
|
|
12
12
|
or users. It has no runtime dependencies and imports no UI framework.
|
|
13
13
|
|
|
14
14
|
> **Status: 0.x release.** Texuli is in development. The packages are
|
|
15
|
-
> published to npm as `0.
|
|
16
|
-
> stable release). Under 0.x, a new minor (`0.
|
|
17
|
-
> changes, so pin the exact version or use `^0.
|
|
15
|
+
> published to npm as `0.8.0`, a pre-1.0 release (1.0.0 will be the first
|
|
16
|
+
> stable release). Under 0.x, a new minor (`0.9.0`) may contain breaking
|
|
17
|
+
> changes, so pin the exact version or use `^0.8.0`, which accepts only 0.8.x.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -59,8 +59,8 @@ window.addEventListener('keydown', (event) => {
|
|
|
59
59
|
|
|
60
60
|
Everything in the table below is available from the root entry
|
|
61
61
|
(`@texuli/core`) and from its own subpath (`@texuli/core/<subpath>`), except
|
|
62
|
-
the editor, lazy-chunk, motion, markup
|
|
63
|
-
subpath-only. Import subpaths to keep bundles narrow.
|
|
62
|
+
the editor, lazy-chunk, motion, markup, ZIP, expression, data-source and
|
|
63
|
+
i18n texels, which are subpath-only. Import subpaths to keep bundles narrow.
|
|
64
64
|
|
|
65
65
|
## What's inside
|
|
66
66
|
|
|
@@ -90,6 +90,9 @@ subpath-only. Import subpaths to keep bundles narrow.
|
|
|
90
90
|
| Rich text editor model | `editor-model`, `editor-transforms`, `editor-history`, `editor-keymap`, `editor-markdown` | Document model, transforms, undo history, keymap, Markdown import and export (subpath only) |
|
|
91
91
|
| Markup builders | `markup` | `el`, `text`, `linkUrl`, `imageUrl`, `serializeXml`, `serializeHtml`: escaped XML and HTML with no raw-markup API (subpath only) |
|
|
92
92
|
| ZIP writer | `zip` | `writeZip`, `writeZipAsync`, `crc32`, `compressBytes`: deterministic archives, deflated where the platform can (subpath only) |
|
|
93
|
+
| Expressions | `expression` | `parseExpression`, `parseExpressionText`, `analyzeExpression`, `evaluateExpression`, `CORE_FUNCTIONS`: the closed, bounded expression language of ADR-015 (subpath only; new in 0.8.0) |
|
|
94
|
+
| Data sources | `data-source` | `defineSource`, `createSourceRegistry`, `checkSourceQuery`, `createSourceLoader`, `createSourceCollection`, `sourceError`: host-registered sources, the closed query, deduplicated, cancellable, retried and cached reads, optimistic writes with rollback, typed errors (ADR-015; subpath only; new in 0.8.0) |
|
|
95
|
+
| Message catalogs | `i18n` | `createI18n`, `parseCatalog`, `formatMessage`, `resolveLabel`, `I18N_CONTEXT`, `pseudoLocalize`: component labels through one catalog, with lazy locales (ADR-016; subpath only; new in 0.8.0) |
|
|
93
96
|
|
|
94
97
|
The table names the headline exports; each subpath's TypeScript
|
|
95
98
|
declarations list the full surface.
|
|
@@ -158,6 +161,9 @@ Ships in `0.6.0`; the `0.5.0` release predates it:
|
|
|
158
161
|
- Rich text editor model: [core-editor.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-editor.md)
|
|
159
162
|
- Static outputs: [core-markup.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-markup.md) (escaped XML and HTML builders) and [core-zip.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-zip.md) (the ZIP writer)
|
|
160
163
|
- Motion (`@texuli/core/motion`: animate, presence, FLIP, shared elements, timelines, springs): [core-motion.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-motion.md)
|
|
164
|
+
- Expressions (`@texuli/core/expression`: the closed AST, its text form, analysis and the bounded evaluator; new in 0.8.0): [core-expression.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-expression.md)
|
|
165
|
+
- Data sources (`@texuli/core/data-source`: declarations, the closed query, the loader, collections and errors; new in 0.8.0): [core-data-source.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-data-source.md)
|
|
166
|
+
- Message catalogs (`@texuli/core/i18n`: catalogs, lazy locales, plural and select messages, the instance prop → context → English resolution and the pseudo-locale; new in 0.8.0): [core-i18n.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-i18n.md)
|
|
161
167
|
- Every texel's contract: [packages/core/docs](https://github.com/DailybotHQ/texuli/tree/main/packages/core/docs)
|
|
162
168
|
- Security model: [SECURITY.md](https://github.com/DailybotHQ/texuli/blob/main/docs/SECURITY.md)
|
|
163
169
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source collection: the rows one view shows for one query, paged, with
|
|
3
|
+
* optimistic writes. Confirmed rows (`base`) and pending writes are kept
|
|
4
|
+
* apart; what a view sees is the base with every pending write applied in
|
|
5
|
+
* order. A write that succeeds folds the source's row into the base; one
|
|
6
|
+
* that fails is dropped, which rolls it back exactly, even while other
|
|
7
|
+
* writes are pending. Framework-free: a view subscribes and re-reads
|
|
8
|
+
* `state`.
|
|
9
|
+
*/
|
|
10
|
+
import { type SourceError } from './errors.js';
|
|
11
|
+
import type { SourceLoader, SourceResult } from './loader.js';
|
|
12
|
+
import type { SourceQuery, SourceValue } from './query.js';
|
|
13
|
+
import type { SourceRow, SourceRowId } from './registry.js';
|
|
14
|
+
import type { SourceDescriptor } from './schema.js';
|
|
15
|
+
export type SourceCollectionStatus = 'idle' | 'loading' | 'ready' | 'error';
|
|
16
|
+
export interface SourceCollectionState {
|
|
17
|
+
readonly status: SourceCollectionStatus;
|
|
18
|
+
/** Confirmed rows with the pending writes applied. */
|
|
19
|
+
readonly rows: readonly SourceRow[];
|
|
20
|
+
/** Why the last load failed (`status` is `error`). Rows from before it stay. */
|
|
21
|
+
readonly error: SourceError | null;
|
|
22
|
+
/** A reload is running over rows already shown. */
|
|
23
|
+
readonly refreshing: boolean;
|
|
24
|
+
readonly hasMore: boolean;
|
|
25
|
+
readonly loadingMore: boolean;
|
|
26
|
+
/** Why the last `loadMore` failed; the loaded rows stay. */
|
|
27
|
+
readonly moreError: SourceError | null;
|
|
28
|
+
readonly total: number | null;
|
|
29
|
+
/** Optimistic writes in flight. */
|
|
30
|
+
readonly pending: number;
|
|
31
|
+
/** The last write that failed (and was rolled back). Cleared by the next write. */
|
|
32
|
+
readonly writeError: SourceError | null;
|
|
33
|
+
/** Bumped on every change. */
|
|
34
|
+
readonly version: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SourceCollectionOptions {
|
|
37
|
+
/** Reload when another view writes to the same source. Default `true`. */
|
|
38
|
+
autoRefresh?: boolean;
|
|
39
|
+
/** Where optimistic creates appear. Default `end`. */
|
|
40
|
+
createAt?: 'start' | 'end';
|
|
41
|
+
}
|
|
42
|
+
export interface SourceCollection {
|
|
43
|
+
readonly state: SourceCollectionState;
|
|
44
|
+
/** The source's descriptor, or `null` when the name or query was refused. */
|
|
45
|
+
readonly descriptor: SourceDescriptor | null;
|
|
46
|
+
/** The validated query, or `null` when it was refused. */
|
|
47
|
+
readonly query: SourceQuery | null;
|
|
48
|
+
/** Load the first page (again). Rows already shown stay until it lands. */
|
|
49
|
+
load(): Promise<void>;
|
|
50
|
+
/** Append the next page, when there is one. */
|
|
51
|
+
loadMore(): Promise<void>;
|
|
52
|
+
/** After a failure: reload, or ask for the failed next page again. */
|
|
53
|
+
retry(): Promise<void>;
|
|
54
|
+
/** Change fields of a row, optimistically. */
|
|
55
|
+
update(id: SourceRowId, changes: Readonly<Record<string, SourceValue>>): Promise<SourceResult<SourceRow>>;
|
|
56
|
+
/** Add a row, optimistically (with a temporary key until the source answers). */
|
|
57
|
+
create(values: Readonly<Record<string, SourceValue>>): Promise<SourceResult<SourceRow>>;
|
|
58
|
+
/** Remove a row, optimistically. */
|
|
59
|
+
remove(id: SourceRowId): Promise<SourceResult<null>>;
|
|
60
|
+
subscribe(listener: (state: SourceCollectionState) => void): () => void;
|
|
61
|
+
/** Cancel everything in flight and stop listening. The state stays readable. */
|
|
62
|
+
destroy(): void;
|
|
63
|
+
}
|
|
64
|
+
/** The prefix of a temporary key: an optimistic row the source has not confirmed. */
|
|
65
|
+
export declare const PENDING_ROW_PREFIX = "pending:";
|
|
66
|
+
/** Create a collection over one source and query. Nothing loads until `load()`. */
|
|
67
|
+
export declare function createSourceCollection(loader: SourceLoader, name: string, query: unknown, options?: SourceCollectionOptions): SourceCollection;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A source collection: the rows one view shows for one query, paged, with
|
|
3
|
+
* optimistic writes. Confirmed rows (`base`) and pending writes are kept
|
|
4
|
+
* apart; what a view sees is the base with every pending write applied in
|
|
5
|
+
* order. A write that succeeds folds the source's row into the base; one
|
|
6
|
+
* that fails is dropped, which rolls it back exactly, even while other
|
|
7
|
+
* writes are pending. Framework-free: a view subscribes and re-reads
|
|
8
|
+
* `state`.
|
|
9
|
+
*/
|
|
10
|
+
import { sourceError } from "./errors.js";
|
|
11
|
+
/** The prefix of a temporary key: an optimistic row the source has not confirmed. */
|
|
12
|
+
export const PENDING_ROW_PREFIX = 'pending:';
|
|
13
|
+
const INITIAL = Object.freeze({
|
|
14
|
+
status: 'idle',
|
|
15
|
+
rows: Object.freeze([]),
|
|
16
|
+
error: null,
|
|
17
|
+
refreshing: false,
|
|
18
|
+
hasMore: false,
|
|
19
|
+
loadingMore: false,
|
|
20
|
+
moreError: null,
|
|
21
|
+
total: null,
|
|
22
|
+
pending: 0,
|
|
23
|
+
writeError: null,
|
|
24
|
+
version: 0,
|
|
25
|
+
});
|
|
26
|
+
function project(row, like, fields, key) {
|
|
27
|
+
const names = like ? Object.keys(like) : fields ? [key, ...fields] : Object.keys(row);
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
if (Object.hasOwn(row, name))
|
|
31
|
+
Object.defineProperty(out, name, { value: row[name] ?? null, enumerable: true });
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze(out);
|
|
34
|
+
}
|
|
35
|
+
/** Create a collection over one source and query. Nothing loads until `load()`. */
|
|
36
|
+
export function createSourceCollection(loader, name, query, options = {}) {
|
|
37
|
+
const resolution = loader.checkQuery(name, query);
|
|
38
|
+
const descriptor = resolution.ok ? resolution.descriptor : null;
|
|
39
|
+
const checked = resolution.ok ? resolution.query : null;
|
|
40
|
+
const key = descriptor?.key ?? 'id';
|
|
41
|
+
const origin = Object.freeze({ collection: name });
|
|
42
|
+
const listeners = new Set();
|
|
43
|
+
let state = resolution.ok
|
|
44
|
+
? INITIAL
|
|
45
|
+
: Object.freeze({ ...INITIAL, status: 'error', error: resolution.error, version: 1 });
|
|
46
|
+
let base = [];
|
|
47
|
+
let pending = [];
|
|
48
|
+
let nextCursor = null;
|
|
49
|
+
let nextOffset = null;
|
|
50
|
+
let loadController = null;
|
|
51
|
+
let moreController = null;
|
|
52
|
+
const writeControllers = new Set();
|
|
53
|
+
let seq = 0;
|
|
54
|
+
let destroyed = false;
|
|
55
|
+
const same = (row, id) => row[key] === id;
|
|
56
|
+
function visible() {
|
|
57
|
+
let rows = base;
|
|
58
|
+
for (const write of pending) {
|
|
59
|
+
if (write.kind === 'update') {
|
|
60
|
+
rows = rows.map((row) => same(row, write.id)
|
|
61
|
+
? Object.freeze({ ...row, ...write.changes, [key]: row[key] ?? null })
|
|
62
|
+
: row);
|
|
63
|
+
}
|
|
64
|
+
else if (write.kind === 'remove') {
|
|
65
|
+
rows = rows.filter((row) => !same(row, write.id));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
rows = options.createAt === 'start' ? [write.row, ...rows] : [...rows, write.row];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Object.freeze(rows);
|
|
72
|
+
}
|
|
73
|
+
function commit(patch) {
|
|
74
|
+
state = Object.freeze({
|
|
75
|
+
...state,
|
|
76
|
+
...patch,
|
|
77
|
+
rows: visible(),
|
|
78
|
+
pending: pending.length,
|
|
79
|
+
version: state.version + 1,
|
|
80
|
+
});
|
|
81
|
+
for (const listener of [...listeners]) {
|
|
82
|
+
try {
|
|
83
|
+
listener(state);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A listener's failure never stops the others.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function applyPage(page) {
|
|
91
|
+
nextCursor = page.nextCursor;
|
|
92
|
+
nextOffset = page.nextOffset;
|
|
93
|
+
}
|
|
94
|
+
async function load() {
|
|
95
|
+
if (destroyed || !checked)
|
|
96
|
+
return;
|
|
97
|
+
loadController?.abort();
|
|
98
|
+
moreController?.abort();
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
loadController = controller;
|
|
101
|
+
const hasRows = base.length > 0;
|
|
102
|
+
commit({
|
|
103
|
+
status: hasRows ? (state.status === 'error' ? 'loading' : 'ready') : 'loading',
|
|
104
|
+
refreshing: hasRows,
|
|
105
|
+
loadingMore: false,
|
|
106
|
+
moreError: null,
|
|
107
|
+
});
|
|
108
|
+
const result = await loader.list(name, checked, { signal: controller.signal, fresh: hasRows });
|
|
109
|
+
if (controller !== loadController || destroyed)
|
|
110
|
+
return;
|
|
111
|
+
loadController = null;
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
if (result.error.code === 'aborted')
|
|
114
|
+
return;
|
|
115
|
+
commit({ status: 'error', error: result.error, refreshing: false });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
base = result.value.rows;
|
|
119
|
+
applyPage(result.value);
|
|
120
|
+
commit({
|
|
121
|
+
status: 'ready',
|
|
122
|
+
error: null,
|
|
123
|
+
refreshing: false,
|
|
124
|
+
hasMore: result.value.hasMore,
|
|
125
|
+
total: result.value.total,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function loadMore() {
|
|
129
|
+
if (destroyed || !checked || !state.hasMore || state.loadingMore || state.status !== 'ready')
|
|
130
|
+
return;
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
moreController = controller;
|
|
133
|
+
commit({ loadingMore: true, moreError: null });
|
|
134
|
+
const result = await loader.list(name, checked, {
|
|
135
|
+
signal: controller.signal,
|
|
136
|
+
...(nextCursor === null ? {} : { cursor: nextCursor }),
|
|
137
|
+
...(nextOffset === null ? {} : { offset: nextOffset }),
|
|
138
|
+
});
|
|
139
|
+
if (controller !== moreController || destroyed)
|
|
140
|
+
return;
|
|
141
|
+
moreController = null;
|
|
142
|
+
if (!result.ok) {
|
|
143
|
+
if (result.error.code === 'aborted')
|
|
144
|
+
return;
|
|
145
|
+
commit({ loadingMore: false, moreError: result.error });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// A row that shifted into the next page while paging is shown once.
|
|
149
|
+
const known = new Set(base.map((row) => `${typeof row[key]}:${String(row[key])}`));
|
|
150
|
+
const fresh = result.value.rows.filter((row) => !known.has(`${typeof row[key]}:${String(row[key])}`));
|
|
151
|
+
base = Object.freeze([...base, ...fresh]);
|
|
152
|
+
applyPage(result.value);
|
|
153
|
+
commit({
|
|
154
|
+
loadingMore: false,
|
|
155
|
+
hasMore: result.value.hasMore,
|
|
156
|
+
total: result.value.total ?? state.total,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async function mutate(write, run, settle) {
|
|
160
|
+
if (destroyed || !descriptor) {
|
|
161
|
+
return { ok: false, error: state.error ?? sourceError('failed', { source: name }) };
|
|
162
|
+
}
|
|
163
|
+
pending = [...pending, write];
|
|
164
|
+
commit({ writeError: null });
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
writeControllers.add(controller);
|
|
167
|
+
const result = await run(controller.signal);
|
|
168
|
+
writeControllers.delete(controller);
|
|
169
|
+
pending = pending.filter((entry) => entry !== write);
|
|
170
|
+
if (destroyed)
|
|
171
|
+
return result;
|
|
172
|
+
if (result.ok) {
|
|
173
|
+
settle(result.value);
|
|
174
|
+
commit({});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Dropping the pending write is the rollback.
|
|
178
|
+
commit({ writeError: result.error });
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
const collection = {
|
|
183
|
+
get state() {
|
|
184
|
+
return state;
|
|
185
|
+
},
|
|
186
|
+
descriptor,
|
|
187
|
+
query: checked,
|
|
188
|
+
load,
|
|
189
|
+
loadMore,
|
|
190
|
+
retry() {
|
|
191
|
+
if (state.status === 'error')
|
|
192
|
+
return load();
|
|
193
|
+
if (state.moreError)
|
|
194
|
+
return loadMore();
|
|
195
|
+
return Promise.resolve();
|
|
196
|
+
},
|
|
197
|
+
update(id, changes) {
|
|
198
|
+
seq += 1;
|
|
199
|
+
const write = { seq, kind: 'update', id, changes: Object.freeze({ ...changes }) };
|
|
200
|
+
return mutate(write, (signal) => loader.update(name, id, changes, { signal, origin }), (row) => {
|
|
201
|
+
base = Object.freeze(base.map((existing) => same(existing, id) ? project(row, existing, checked?.fields, key) : existing));
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
create(values) {
|
|
205
|
+
seq += 1;
|
|
206
|
+
const temp = Object.freeze({ ...values, [key]: `${PENDING_ROW_PREFIX}${seq}` });
|
|
207
|
+
const write = { seq, kind: 'create', row: temp };
|
|
208
|
+
return mutate(write, (signal) => loader.create(name, values, { signal, origin }), (row) => {
|
|
209
|
+
if (base.some((existing) => same(existing, row[key])))
|
|
210
|
+
return;
|
|
211
|
+
const added = project(row, undefined, checked?.fields, key);
|
|
212
|
+
base = Object.freeze(options.createAt === 'start' ? [added, ...base] : [...base, added]);
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
remove(id) {
|
|
216
|
+
seq += 1;
|
|
217
|
+
const write = { seq, kind: 'remove', id };
|
|
218
|
+
return mutate(write, (signal) => loader.remove(name, id, { signal, origin }), () => {
|
|
219
|
+
base = Object.freeze(base.filter((existing) => !same(existing, id)));
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
subscribe(listener) {
|
|
223
|
+
listeners.add(listener);
|
|
224
|
+
return () => listeners.delete(listener);
|
|
225
|
+
},
|
|
226
|
+
destroy() {
|
|
227
|
+
if (destroyed)
|
|
228
|
+
return;
|
|
229
|
+
destroyed = true;
|
|
230
|
+
unsubscribe();
|
|
231
|
+
loadController?.abort();
|
|
232
|
+
moreController?.abort();
|
|
233
|
+
for (const controller of writeControllers)
|
|
234
|
+
controller.abort();
|
|
235
|
+
writeControllers.clear();
|
|
236
|
+
listeners.clear();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
// Another view's write to this source reloads this one; this view's own writes do not.
|
|
240
|
+
const unsubscribe = options.autoRefresh === false || !descriptor
|
|
241
|
+
? () => { }
|
|
242
|
+
: loader.subscribe(name, (event) => {
|
|
243
|
+
if (event.origin !== origin && state.status !== 'idle')
|
|
244
|
+
void load();
|
|
245
|
+
});
|
|
246
|
+
return Object.freeze(collection);
|
|
247
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed error model of data sources. Every failure is a `SourceError`
|
|
3
|
+
* with a closed code, a `retryable` flag and a user-facing `messageKey`;
|
|
4
|
+
* its `message` is for developers and is never shown to people. Adapters
|
|
5
|
+
* throw `sourceError(code)` to say what went wrong; anything else they
|
|
6
|
+
* throw becomes `failed` (not retryable), and its text stays in `cause`.
|
|
7
|
+
*/
|
|
8
|
+
import type { SourceQueryIssue } from './query.js';
|
|
9
|
+
export declare const SOURCE_ERROR_CODES: readonly ["unknown-source", "unsupported", "invalid-query", "invalid-input", "invalid-response", "not-found", "conflict", "unauthorized", "forbidden", "rate-limited", "timeout", "network", "unavailable", "aborted", "failed"];
|
|
10
|
+
export type SourceErrorCode = (typeof SOURCE_ERROR_CODES)[number];
|
|
11
|
+
/** Codes a retry may fix. The loader retries these with backoff (reads only). */
|
|
12
|
+
export declare const RETRYABLE_SOURCE_ERRORS: readonly SourceErrorCode[];
|
|
13
|
+
/**
|
|
14
|
+
* Codes that describe the view's own request (a source the host never
|
|
15
|
+
* registered, a refused query or input, a cancelled call): asking again
|
|
16
|
+
* cannot help, so a view offers no retry for them.
|
|
17
|
+
*/
|
|
18
|
+
export declare const FINAL_SOURCE_ERRORS: readonly SourceErrorCode[];
|
|
19
|
+
/** Whether a person asking again may help (a retry button), unlike `retryable` (automatic retries). */
|
|
20
|
+
export declare function canRetrySourceError(error: Pick<SourceError, 'code'>): boolean;
|
|
21
|
+
/** The i18n key of an error's user-facing message: `dataSource.error.<code>`. */
|
|
22
|
+
export type SourceErrorMessageKey = `dataSource.error.${SourceErrorCode}`;
|
|
23
|
+
export interface SourceError extends Error {
|
|
24
|
+
readonly name: 'SourceError';
|
|
25
|
+
readonly code: SourceErrorCode;
|
|
26
|
+
readonly retryable: boolean;
|
|
27
|
+
readonly messageKey: SourceErrorMessageKey;
|
|
28
|
+
/** The source the operation addressed, when known. */
|
|
29
|
+
readonly source?: string;
|
|
30
|
+
/** Query or input issues (`invalid-query`, `invalid-input`). */
|
|
31
|
+
readonly issues?: readonly SourceQueryIssue[];
|
|
32
|
+
/** A server's retry hint for `rate-limited` or `unavailable`, in milliseconds. */
|
|
33
|
+
readonly retryAfterMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/** English defaults for every message key; hosts translate by key. */
|
|
36
|
+
export declare const SOURCE_ERROR_MESSAGES: Readonly<Record<SourceErrorMessageKey, string>>;
|
|
37
|
+
export interface SourceErrorInit {
|
|
38
|
+
/** Developer-facing detail. Never shown to people. */
|
|
39
|
+
message?: string;
|
|
40
|
+
/** Overrides the code's default (`RETRYABLE_SOURCE_ERRORS`). */
|
|
41
|
+
retryable?: boolean;
|
|
42
|
+
source?: string;
|
|
43
|
+
issues?: readonly SourceQueryIssue[];
|
|
44
|
+
retryAfterMs?: number;
|
|
45
|
+
cause?: unknown;
|
|
46
|
+
}
|
|
47
|
+
/** Make a typed source error. Adapters throw these; the loader recognizes only these. */
|
|
48
|
+
export declare function sourceError(code: SourceErrorCode, init?: SourceErrorInit): SourceError;
|
|
49
|
+
/** Whether `value` was made by {@link sourceError} (a look-alike object is not). */
|
|
50
|
+
export declare function isSourceError(value: unknown): value is SourceError;
|
|
51
|
+
/**
|
|
52
|
+
* Whatever an adapter threw, as a source error: a `sourceError` keeps its
|
|
53
|
+
* code (and gains the source name), an `AbortError` is `aborted`, and
|
|
54
|
+
* anything else is `failed` with the original in `cause`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function toSourceError(thrown: unknown, source?: string): SourceError;
|
|
57
|
+
/** The user-facing English default of an error (hosts translate `messageKey` instead). */
|
|
58
|
+
export declare function sourceErrorMessage(error: Pick<SourceError, 'messageKey'>, messages?: Partial<Record<SourceErrorMessageKey, string>>): string;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed error model of data sources. Every failure is a `SourceError`
|
|
3
|
+
* with a closed code, a `retryable` flag and a user-facing `messageKey`;
|
|
4
|
+
* its `message` is for developers and is never shown to people. Adapters
|
|
5
|
+
* throw `sourceError(code)` to say what went wrong; anything else they
|
|
6
|
+
* throw becomes `failed` (not retryable), and its text stays in `cause`.
|
|
7
|
+
*/
|
|
8
|
+
export const SOURCE_ERROR_CODES = [
|
|
9
|
+
'unknown-source',
|
|
10
|
+
'unsupported',
|
|
11
|
+
'invalid-query',
|
|
12
|
+
'invalid-input',
|
|
13
|
+
'invalid-response',
|
|
14
|
+
'not-found',
|
|
15
|
+
'conflict',
|
|
16
|
+
'unauthorized',
|
|
17
|
+
'forbidden',
|
|
18
|
+
'rate-limited',
|
|
19
|
+
'timeout',
|
|
20
|
+
'network',
|
|
21
|
+
'unavailable',
|
|
22
|
+
'aborted',
|
|
23
|
+
'failed',
|
|
24
|
+
];
|
|
25
|
+
/** Codes a retry may fix. The loader retries these with backoff (reads only). */
|
|
26
|
+
export const RETRYABLE_SOURCE_ERRORS = Object.freeze([
|
|
27
|
+
'rate-limited',
|
|
28
|
+
'timeout',
|
|
29
|
+
'network',
|
|
30
|
+
'unavailable',
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Codes that describe the view's own request (a source the host never
|
|
34
|
+
* registered, a refused query or input, a cancelled call): asking again
|
|
35
|
+
* cannot help, so a view offers no retry for them.
|
|
36
|
+
*/
|
|
37
|
+
export const FINAL_SOURCE_ERRORS = Object.freeze([
|
|
38
|
+
'unknown-source',
|
|
39
|
+
'unsupported',
|
|
40
|
+
'invalid-query',
|
|
41
|
+
'invalid-input',
|
|
42
|
+
'aborted',
|
|
43
|
+
]);
|
|
44
|
+
/** Whether a person asking again may help (a retry button), unlike `retryable` (automatic retries). */
|
|
45
|
+
export function canRetrySourceError(error) {
|
|
46
|
+
return !FINAL_SOURCE_ERRORS.includes(error.code);
|
|
47
|
+
}
|
|
48
|
+
/** English defaults for every message key; hosts translate by key. */
|
|
49
|
+
export const SOURCE_ERROR_MESSAGES = Object.freeze({
|
|
50
|
+
'dataSource.error.unknown-source': 'This data is not available here.',
|
|
51
|
+
'dataSource.error.unsupported': 'This data cannot be changed here.',
|
|
52
|
+
'dataSource.error.invalid-query': 'This view asks for data in a way the source does not allow.',
|
|
53
|
+
'dataSource.error.invalid-input': 'Some values are not valid.',
|
|
54
|
+
'dataSource.error.invalid-response': 'The data that came back could not be read.',
|
|
55
|
+
'dataSource.error.not-found': 'This item no longer exists.',
|
|
56
|
+
'dataSource.error.conflict': 'Someone else changed this item. Reload and try again.',
|
|
57
|
+
'dataSource.error.unauthorized': 'Sign in to see this data.',
|
|
58
|
+
'dataSource.error.forbidden': 'You do not have access to this data.',
|
|
59
|
+
'dataSource.error.rate-limited': 'Too many requests. Try again in a moment.',
|
|
60
|
+
'dataSource.error.timeout': 'Loading took too long.',
|
|
61
|
+
'dataSource.error.network': 'The connection was lost.',
|
|
62
|
+
'dataSource.error.unavailable': 'The data source is unavailable right now.',
|
|
63
|
+
'dataSource.error.aborted': 'Loading was cancelled.',
|
|
64
|
+
'dataSource.error.failed': 'Something went wrong loading this data.',
|
|
65
|
+
});
|
|
66
|
+
const BRAND = new WeakSet();
|
|
67
|
+
/** Make a typed source error. Adapters throw these; the loader recognizes only these. */
|
|
68
|
+
export function sourceError(code, init = {}) {
|
|
69
|
+
const known = SOURCE_ERROR_CODES.includes(code);
|
|
70
|
+
const safe = known ? code : 'failed';
|
|
71
|
+
const error = new Error(init.message ?? `Data source error: ${safe}.`, {
|
|
72
|
+
...(init.cause === undefined ? {} : { cause: init.cause }),
|
|
73
|
+
});
|
|
74
|
+
const retryAfter = typeof init.retryAfterMs === 'number' &&
|
|
75
|
+
Number.isFinite(init.retryAfterMs) &&
|
|
76
|
+
init.retryAfterMs >= 0
|
|
77
|
+
? init.retryAfterMs
|
|
78
|
+
: undefined;
|
|
79
|
+
Object.defineProperties(error, {
|
|
80
|
+
name: { value: 'SourceError', enumerable: false },
|
|
81
|
+
code: { value: safe, enumerable: true },
|
|
82
|
+
retryable: {
|
|
83
|
+
value: init.retryable ?? RETRYABLE_SOURCE_ERRORS.includes(safe),
|
|
84
|
+
enumerable: true,
|
|
85
|
+
},
|
|
86
|
+
messageKey: { value: `dataSource.error.${safe}`, enumerable: true },
|
|
87
|
+
...(init.source === undefined ? {} : { source: { value: init.source, enumerable: true } }),
|
|
88
|
+
...(init.issues === undefined
|
|
89
|
+
? {}
|
|
90
|
+
: { issues: { value: Object.freeze([...init.issues]), enumerable: true } }),
|
|
91
|
+
...(retryAfter === undefined ? {} : { retryAfterMs: { value: retryAfter, enumerable: true } }),
|
|
92
|
+
});
|
|
93
|
+
BRAND.add(error);
|
|
94
|
+
return Object.freeze(error);
|
|
95
|
+
}
|
|
96
|
+
/** Whether `value` was made by {@link sourceError} (a look-alike object is not). */
|
|
97
|
+
export function isSourceError(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null && BRAND.has(value);
|
|
99
|
+
}
|
|
100
|
+
function isAbort(value) {
|
|
101
|
+
return (typeof value === 'object' &&
|
|
102
|
+
value !== null &&
|
|
103
|
+
value.name === 'AbortError');
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Whatever an adapter threw, as a source error: a `sourceError` keeps its
|
|
107
|
+
* code (and gains the source name), an `AbortError` is `aborted`, and
|
|
108
|
+
* anything else is `failed` with the original in `cause`.
|
|
109
|
+
*/
|
|
110
|
+
export function toSourceError(thrown, source) {
|
|
111
|
+
if (isSourceError(thrown)) {
|
|
112
|
+
if (source === undefined || thrown.source !== undefined)
|
|
113
|
+
return thrown;
|
|
114
|
+
return sourceError(thrown.code, {
|
|
115
|
+
message: thrown.message,
|
|
116
|
+
retryable: thrown.retryable,
|
|
117
|
+
source,
|
|
118
|
+
...(thrown.issues ? { issues: thrown.issues } : {}),
|
|
119
|
+
...(thrown.retryAfterMs === undefined ? {} : { retryAfterMs: thrown.retryAfterMs }),
|
|
120
|
+
cause: thrown.cause,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (isAbort(thrown)) {
|
|
124
|
+
return sourceError('aborted', { ...(source === undefined ? {} : { source }), cause: thrown });
|
|
125
|
+
}
|
|
126
|
+
return sourceError('failed', {
|
|
127
|
+
message: 'The data source failed.',
|
|
128
|
+
...(source === undefined ? {} : { source }),
|
|
129
|
+
cause: thrown,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** The user-facing English default of an error (hosts translate `messageKey` instead). */
|
|
133
|
+
export function sourceErrorMessage(error, messages = {}) {
|
|
134
|
+
const key = error.messageKey;
|
|
135
|
+
return ((Object.hasOwn(messages, key) ? messages[key] : undefined) ??
|
|
136
|
+
SOURCE_ERROR_MESSAGES[key] ??
|
|
137
|
+
SOURCE_ERROR_MESSAGES['dataSource.error.failed']);
|
|
138
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/data-source: host-registered data sources for generated
|
|
3
|
+
* views (ADR-015). The host declares each source (typed fields, the
|
|
4
|
+
* operations, filter and sort capabilities, pagination) and writes its
|
|
5
|
+
* adapter; a blueprint names a source and sends a closed query. The loader
|
|
6
|
+
* validates, deduplicates, cancels, retries, caches and invalidates; a
|
|
7
|
+
* collection pages and applies optimistic writes with rollback. Errors are
|
|
8
|
+
* typed, with a retryable flag and a user-facing message key.
|
|
9
|
+
*
|
|
10
|
+
* Framework-free and not re-exported from the `@texuli/core` root barrel.
|
|
11
|
+
*/
|
|
12
|
+
export { createSourceCollection, PENDING_ROW_PREFIX, type SourceCollection, type SourceCollectionOptions, type SourceCollectionState, type SourceCollectionStatus, } from './collection.js';
|
|
13
|
+
export { canRetrySourceError, FINAL_SOURCE_ERRORS, isSourceError, RETRYABLE_SOURCE_ERRORS, SOURCE_ERROR_CODES, SOURCE_ERROR_MESSAGES, type SourceError, type SourceErrorCode, type SourceErrorInit, type SourceErrorMessageKey, sourceError, sourceErrorMessage, toSourceError, } from './errors.js';
|
|
14
|
+
export { createSourceLoader, DEFAULT_SOURCE_RETRY, isSourceRowId, MAX_RETRY_AFTER_MS, readSourceRow, type SourceCallOptions, type SourceClock, type SourceInvalidation, type SourceListOptions, type SourceListResult, type SourceLoader, type SourceLoaderOptions, type SourceMutationOptions, type SourceQueryResolution, type SourceResult, type SourceRetryPolicy, SYSTEM_SOURCE_CLOCK, } from './loader.js';
|
|
15
|
+
export { checkSourceQuery, fitsSourceField, isSourceDate, SOURCE_QUERY_KEYS, type SourceFilterCondition, type SourceFilterGroup, type SourceFilterNode, type SourceQuery, type SourceQueryCheck, type SourceQueryIssue, type SourceQueryIssueCode, type SourceSort, type SourceValue, sourceQueryKey, stableJson, } from './query.js';
|
|
16
|
+
export { createSourceRegistry, type DataSource, defineSource, type SourceAdapter, type SourceListRequest, type SourcePage, type SourcePageRequest, type SourceRegistry, type SourceRow, type SourceRowId, } from './registry.js';
|
|
17
|
+
export { describeSource, isSourceFieldName, isSourceName, SOURCE_FIELD_PATTERN, SOURCE_FIELD_TYPES, SOURCE_FILTER_OPERATORS, SOURCE_LIMITS, SOURCE_NAME_PATTERN, SOURCE_OPERATIONS, SOURCE_OPERATORS_BY_TYPE, SOURCE_PAGINATION_STYLES, type SourceDescriptor, type SourceFieldDeclaration, type SourceFieldDescriptor, type SourceFieldType, type SourceFilterOperator, type SourceOperation, type SourcePaginationStyle, type SourceSchemaDeclaration, sourceField, } from './schema.js';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @texuli/core/data-source: host-registered data sources for generated
|
|
3
|
+
* views (ADR-015). The host declares each source (typed fields, the
|
|
4
|
+
* operations, filter and sort capabilities, pagination) and writes its
|
|
5
|
+
* adapter; a blueprint names a source and sends a closed query. The loader
|
|
6
|
+
* validates, deduplicates, cancels, retries, caches and invalidates; a
|
|
7
|
+
* collection pages and applies optimistic writes with rollback. Errors are
|
|
8
|
+
* typed, with a retryable flag and a user-facing message key.
|
|
9
|
+
*
|
|
10
|
+
* Framework-free and not re-exported from the `@texuli/core` root barrel.
|
|
11
|
+
*/
|
|
12
|
+
export { createSourceCollection, PENDING_ROW_PREFIX, } from "./collection.js";
|
|
13
|
+
export { canRetrySourceError, FINAL_SOURCE_ERRORS, isSourceError, RETRYABLE_SOURCE_ERRORS, SOURCE_ERROR_CODES, SOURCE_ERROR_MESSAGES, sourceError, sourceErrorMessage, toSourceError, } from "./errors.js";
|
|
14
|
+
export { createSourceLoader, DEFAULT_SOURCE_RETRY, isSourceRowId, MAX_RETRY_AFTER_MS, readSourceRow, SYSTEM_SOURCE_CLOCK, } from "./loader.js";
|
|
15
|
+
export { checkSourceQuery, fitsSourceField, isSourceDate, SOURCE_QUERY_KEYS, sourceQueryKey, stableJson, } from "./query.js";
|
|
16
|
+
export { createSourceRegistry, defineSource, } from "./registry.js";
|
|
17
|
+
export { describeSource, isSourceFieldName, isSourceName, SOURCE_FIELD_PATTERN, SOURCE_FIELD_TYPES, SOURCE_FILTER_OPERATORS, SOURCE_LIMITS, SOURCE_NAME_PATTERN, SOURCE_OPERATIONS, SOURCE_OPERATORS_BY_TYPE, SOURCE_PAGINATION_STYLES, sourceField, } from "./schema.js";
|