@texuli/core 0.7.0 → 0.9.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 (48) 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/dist/index.d.ts +1 -1
  43. package/dist/index.js +1 -1
  44. package/dist/portal/index.d.ts +10 -1
  45. package/dist/portal/index.js +44 -32
  46. package/dist/portal/scope.d.ts +58 -0
  47. package/dist/portal/scope.js +118 -0
  48. package/package.json +4 -1
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The closed query model (ADR-015): projection, a filter tree in the
3
+ * `@texuli/data` grid filter model's shape, sort keys, search text and a
4
+ * page size. `checkSourceQuery` validates untrusted input against a source
5
+ * descriptor without recursion past the depth cap and in time linear in the
6
+ * input; the adapter receives the validated copy, never the input.
7
+ */
8
+ import { type SourceDescriptor, type SourceFieldDescriptor, type SourceFilterOperator } from './schema.js';
9
+ /** A value a row field or a filter holds. */
10
+ export type SourceValue = string | number | boolean | null;
11
+ /** One filter condition (the grid model's `FilterCondition`). */
12
+ export interface SourceFilterCondition {
13
+ readonly type: 'condition';
14
+ readonly field: string;
15
+ readonly operator: SourceFilterOperator;
16
+ /** eq/neq: a scalar; contains: text; in: a list; between: [low, high]; before/after: a date. */
17
+ readonly value?: SourceValue | readonly SourceValue[];
18
+ }
19
+ /** An and/or group (the grid model's `FilterGroup`). */
20
+ export interface SourceFilterGroup {
21
+ readonly type: 'group';
22
+ readonly op: 'and' | 'or';
23
+ readonly children: readonly SourceFilterNode[];
24
+ }
25
+ export type SourceFilterNode = SourceFilterCondition | SourceFilterGroup;
26
+ export interface SourceSort {
27
+ readonly field: string;
28
+ readonly direction: 'asc' | 'desc';
29
+ }
30
+ /** A validated query. Every member is optional. */
31
+ export interface SourceQuery {
32
+ /** Fields to return; the key always comes back. Omitted: every field. */
33
+ readonly fields?: readonly string[];
34
+ readonly filter?: SourceFilterNode;
35
+ readonly sort?: readonly SourceSort[];
36
+ /** Free text for a source that declares `search`. Passed to the adapter as data. */
37
+ readonly search?: string;
38
+ /** Rows per page, at most the source's `maxPageSize`. Omitted: its `defaultPageSize`. */
39
+ readonly pageSize?: number;
40
+ }
41
+ /** The query members, in canonical order. Anything else is refused. */
42
+ export declare const SOURCE_QUERY_KEYS: readonly ["fields", "filter", "sort", "search", "pageSize"];
43
+ export type SourceQueryIssueCode = 'shape' | 'limit' | 'unknown-field' | 'operator' | 'value' | 'sort' | 'search' | 'page-size' | 'operation';
44
+ export interface SourceQueryIssue {
45
+ readonly code: SourceQueryIssueCode;
46
+ /** A JSON path into the query (`$.filter.children[0].value`). */
47
+ readonly path: string;
48
+ readonly message: string;
49
+ }
50
+ export type SourceQueryCheck = {
51
+ readonly ok: true;
52
+ readonly query: SourceQuery;
53
+ } | {
54
+ readonly ok: false;
55
+ readonly issues: readonly SourceQueryIssue[];
56
+ };
57
+ /** Whether `value` is a date: an ISO 8601 date or date-time string, or epoch milliseconds. */
58
+ export declare function isSourceDate(value: unknown): boolean;
59
+ /** Whether `value` is a legal value of `field` (`null` is always legal: no value). */
60
+ export declare function fitsSourceField(field: SourceFieldDescriptor, value: unknown): boolean;
61
+ /**
62
+ * Validate an untrusted query against a source. On success the query is a
63
+ * frozen copy holding only checked members; on failure every issue (up to
64
+ * 32) has a code and a path. Strings are data: they reach the adapter as
65
+ * they came, and nothing here concatenates them into anything.
66
+ */
67
+ export declare function checkSourceQuery(descriptor: SourceDescriptor, input: unknown): SourceQueryCheck;
68
+ /**
69
+ * Canonical JSON of plain data: object keys sorted at every depth, arrays in
70
+ * order. Two equal queries give the same string, whatever their key order.
71
+ * Only call it on validated data (the depth is already bounded).
72
+ */
73
+ export declare function stableJson(value: unknown): string;
74
+ /** The cache and deduplication key of one page of one validated query. */
75
+ export declare function sourceQueryKey(source: string, query: SourceQuery, page?: {
76
+ readonly cursor?: string | null;
77
+ readonly offset?: number | null;
78
+ }): string;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * The closed query model (ADR-015): projection, a filter tree in the
3
+ * `@texuli/data` grid filter model's shape, sort keys, search text and a
4
+ * page size. `checkSourceQuery` validates untrusted input against a source
5
+ * descriptor without recursion past the depth cap and in time linear in the
6
+ * input; the adapter receives the validated copy, never the input.
7
+ */
8
+ import { isSourceFieldName, SOURCE_FILTER_OPERATORS, SOURCE_LIMITS, sourceField, } from "./schema.js";
9
+ /** The query members, in canonical order. Anything else is refused. */
10
+ export const SOURCE_QUERY_KEYS = ['fields', 'filter', 'sort', 'search', 'pageSize'];
11
+ function isBag(value) {
12
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ }
14
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?$/;
15
+ /** Whether `value` is a date: an ISO 8601 date or date-time string, or epoch milliseconds. */
16
+ export function isSourceDate(value) {
17
+ if (typeof value === 'number')
18
+ return Number.isFinite(value);
19
+ return (typeof value === 'string' &&
20
+ value.length <= 35 &&
21
+ DATE_RE.test(value) &&
22
+ !Number.isNaN(Date.parse(value)));
23
+ }
24
+ /** Whether `value` is a legal value of `field` (`null` is always legal: no value). */
25
+ export function fitsSourceField(field, value) {
26
+ if (value === null)
27
+ return true;
28
+ switch (field.type) {
29
+ case 'text':
30
+ return typeof value === 'string';
31
+ case 'enum':
32
+ return typeof value === 'string' && (field.options ?? []).includes(value);
33
+ case 'number':
34
+ return typeof value === 'number' && Number.isFinite(value);
35
+ case 'date':
36
+ return isSourceDate(value);
37
+ case 'boolean':
38
+ return typeof value === 'boolean';
39
+ default:
40
+ return false;
41
+ }
42
+ }
43
+ function isScalar(value) {
44
+ return (value === null ||
45
+ typeof value === 'boolean' ||
46
+ (typeof value === 'number' && Number.isFinite(value)) ||
47
+ (typeof value === 'string' && value.length <= SOURCE_LIMITS.valueLength));
48
+ }
49
+ /**
50
+ * Whether the input filter is over the depth or node caps, measured without
51
+ * recursion so a tree nested thousands of levels deep is refused before
52
+ * anything recurses into it. Stops at the first excess.
53
+ */
54
+ function filterOverLimits(input) {
55
+ const stack = [{ node: input, depth: 1 }];
56
+ let nodes = 0;
57
+ while (stack.length > 0) {
58
+ const { node, depth } = stack.pop();
59
+ nodes += 1;
60
+ if (depth > SOURCE_LIMITS.filterDepth || nodes > SOURCE_LIMITS.filterNodes)
61
+ return true;
62
+ const children = isBag(node) ? node.children : undefined;
63
+ if (!Array.isArray(children))
64
+ continue;
65
+ if (nodes + stack.length + children.length > SOURCE_LIMITS.filterNodes)
66
+ return true;
67
+ for (const child of children)
68
+ stack.push({ node: child, depth: depth + 1 });
69
+ }
70
+ return false;
71
+ }
72
+ function report(checker, code, path, message) {
73
+ // One bad query can say a lot; the first 32 issues are enough to fix it.
74
+ if (checker.issues.length < 32)
75
+ checker.issues.push(Object.freeze({ code, path, message }));
76
+ }
77
+ /** An object with exactly the allowed own keys (a JSON `__proto__` key is an own key, and refused). */
78
+ function closedKeys(checker, value, allowed, path) {
79
+ let ok = true;
80
+ for (const key of Object.keys(value)) {
81
+ if (!allowed.includes(key)) {
82
+ report(checker, 'shape', `${path}.${key}`, `"${key}" is not part of a query here.`);
83
+ ok = false;
84
+ }
85
+ }
86
+ return ok;
87
+ }
88
+ function knownField(checker, name, path) {
89
+ if (!isSourceFieldName(name)) {
90
+ report(checker, 'shape', path, 'A field is named by a field name.');
91
+ return undefined;
92
+ }
93
+ const field = sourceField(checker.descriptor, name);
94
+ if (!field) {
95
+ report(checker, 'unknown-field', path, `"${checker.descriptor.name}" has no "${name}" field.`);
96
+ }
97
+ return field;
98
+ }
99
+ function checkCondition(checker, input, path) {
100
+ if (!closedKeys(checker, input, ['type', 'field', 'operator', 'value'], path))
101
+ return null;
102
+ const field = knownField(checker, input.field, `${path}.field`);
103
+ const operator = input.operator;
104
+ if (!SOURCE_FILTER_OPERATORS.includes(operator)) {
105
+ report(checker, 'operator', `${path}.operator`, 'The operator is not a filter operator.');
106
+ return null;
107
+ }
108
+ if (!field)
109
+ return null;
110
+ const op = operator;
111
+ if (!field.filter.includes(op)) {
112
+ report(checker, 'operator', `${path}.operator`, `"${field.name}" cannot be filtered by "${op}".`);
113
+ return null;
114
+ }
115
+ const here = `${path}.value`;
116
+ const has = Object.hasOwn(input, 'value');
117
+ const value = input.value;
118
+ const fail = (message) => {
119
+ report(checker, 'value', here, message);
120
+ return null;
121
+ };
122
+ // A list value is checked item by item against the field's type.
123
+ const fitsAll = (list) => list.every((item) => isScalar(item) && fitsSourceField(field, item));
124
+ if (op === 'isEmpty' || op === 'isNotEmpty') {
125
+ if (has)
126
+ return fail(`${op} takes no value.`);
127
+ return Object.freeze({ type: 'condition', field: field.name, operator: op });
128
+ }
129
+ if (op === 'in') {
130
+ if (!Array.isArray(value) || value.length === 0 || value.length > SOURCE_LIMITS.filterValues) {
131
+ return fail(`in needs a list of 1 to ${SOURCE_LIMITS.filterValues} values.`);
132
+ }
133
+ if (!fitsAll(value))
134
+ return fail(`Every value must fit "${field.name}" (${field.type}).`);
135
+ return Object.freeze({
136
+ type: 'condition',
137
+ field: field.name,
138
+ operator: op,
139
+ value: Object.freeze([...value]),
140
+ });
141
+ }
142
+ if (op === 'between') {
143
+ if (!Array.isArray(value) || value.length !== 2 || value.includes(null) || !fitsAll(value)) {
144
+ return fail(`between needs [low, high] values of "${field.name}" (${field.type}).`);
145
+ }
146
+ return Object.freeze({
147
+ type: 'condition',
148
+ field: field.name,
149
+ operator: op,
150
+ value: Object.freeze([...value]),
151
+ });
152
+ }
153
+ if (op === 'contains' && (typeof value !== 'string' || value === '')) {
154
+ return fail('contains needs text.');
155
+ }
156
+ if ((op === 'before' || op === 'after') && !isSourceDate(value)) {
157
+ return fail(`${op} needs a date.`);
158
+ }
159
+ if (!has || !isScalar(value) || !fitsSourceField(field, value)) {
160
+ return fail(`${op} needs one value of "${field.name}" (${field.type}).`);
161
+ }
162
+ return Object.freeze({ type: 'condition', field: field.name, operator: op, value });
163
+ }
164
+ /** Recursion is safe here: `filterOverLimits` capped the depth at 6 first. */
165
+ function checkFilter(checker, input, path) {
166
+ if (!isBag(input)) {
167
+ report(checker, 'shape', path, 'A filter node is an object.');
168
+ return null;
169
+ }
170
+ if (input.type === 'group') {
171
+ if (!closedKeys(checker, input, ['type', 'op', 'children'], path))
172
+ return null;
173
+ if (input.op !== 'and' && input.op !== 'or') {
174
+ report(checker, 'shape', `${path}.op`, 'A group is "and" or "or".');
175
+ return null;
176
+ }
177
+ if (!Array.isArray(input.children) || input.children.length === 0) {
178
+ report(checker, 'shape', `${path}.children`, 'A group needs at least one child.');
179
+ return null;
180
+ }
181
+ const children = [];
182
+ input.children.forEach((child, index) => {
183
+ const checked = checkFilter(checker, child, `${path}.children[${index}]`);
184
+ if (checked)
185
+ children.push(checked);
186
+ });
187
+ if (children.length !== input.children.length)
188
+ return null;
189
+ return Object.freeze({ type: 'group', op: input.op, children: Object.freeze(children) });
190
+ }
191
+ if (input.type !== 'condition') {
192
+ report(checker, 'shape', `${path}.type`, 'A filter node is a "condition" or a "group".');
193
+ return null;
194
+ }
195
+ return checkCondition(checker, input, path);
196
+ }
197
+ function checkFields(checker, input) {
198
+ if (!Array.isArray(input) || input.length === 0 || input.length > SOURCE_LIMITS.projection) {
199
+ report(checker, 'shape', '$.fields', `fields lists 1 to ${SOURCE_LIMITS.projection} field names.`);
200
+ return null;
201
+ }
202
+ const out = [];
203
+ input.forEach((name, index) => {
204
+ const field = knownField(checker, name, `$.fields[${index}]`);
205
+ if (field && !out.includes(field.name))
206
+ out.push(field.name);
207
+ });
208
+ // A repeated name is dropped; an unknown one was reported and fails the query.
209
+ return Object.freeze(out);
210
+ }
211
+ function checkSort(checker, input) {
212
+ if (!Array.isArray(input) || input.length === 0 || input.length > SOURCE_LIMITS.sort) {
213
+ report(checker, 'shape', '$.sort', `sort lists 1 to ${SOURCE_LIMITS.sort} keys.`);
214
+ return null;
215
+ }
216
+ const out = [];
217
+ input.forEach((entry, index) => {
218
+ const path = `$.sort[${index}]`;
219
+ if (!isBag(entry) || !closedKeys(checker, entry, ['field', 'direction'], path)) {
220
+ if (!isBag(entry))
221
+ report(checker, 'shape', path, 'A sort key is an object.');
222
+ return;
223
+ }
224
+ const direction = entry.direction ?? 'asc';
225
+ if (direction !== 'asc' && direction !== 'desc') {
226
+ report(checker, 'shape', `${path}.direction`, 'direction is "asc" or "desc".');
227
+ return;
228
+ }
229
+ const field = knownField(checker, entry.field, `${path}.field`);
230
+ if (!field)
231
+ return;
232
+ if (!field.sort) {
233
+ report(checker, 'sort', `${path}.field`, `"${field.name}" is not sortable.`);
234
+ return;
235
+ }
236
+ if (out.some((sort) => sort.field === field.name)) {
237
+ report(checker, 'sort', `${path}.field`, `"${field.name}" is sorted twice.`);
238
+ return;
239
+ }
240
+ out.push(Object.freeze({ field: field.name, direction }));
241
+ });
242
+ return Object.freeze(out);
243
+ }
244
+ /**
245
+ * Validate an untrusted query against a source. On success the query is a
246
+ * frozen copy holding only checked members; on failure every issue (up to
247
+ * 32) has a code and a path. Strings are data: they reach the adapter as
248
+ * they came, and nothing here concatenates them into anything.
249
+ */
250
+ export function checkSourceQuery(descriptor, input) {
251
+ const checker = { descriptor, issues: [] };
252
+ const lists = descriptor.operations.includes('list') || descriptor.operations.includes('search');
253
+ if (!lists) {
254
+ report(checker, 'operation', '$', `"${descriptor.name}" cannot list rows.`);
255
+ return { ok: false, issues: checker.issues };
256
+ }
257
+ if (input === undefined)
258
+ return { ok: true, query: Object.freeze({}) };
259
+ if (!isBag(input)) {
260
+ report(checker, 'shape', '$', 'A query is an object.');
261
+ return { ok: false, issues: checker.issues };
262
+ }
263
+ closedKeys(checker, input, SOURCE_QUERY_KEYS, '$');
264
+ const query = {};
265
+ if (Object.hasOwn(input, 'fields')) {
266
+ const fields = checkFields(checker, input.fields);
267
+ if (fields)
268
+ query.fields = fields;
269
+ }
270
+ if (Object.hasOwn(input, 'filter')) {
271
+ if (filterOverLimits(input.filter)) {
272
+ report(checker, 'limit', '$.filter', `A filter has at most ${SOURCE_LIMITS.filterDepth} levels and ${SOURCE_LIMITS.filterNodes} nodes.`);
273
+ }
274
+ else {
275
+ const filter = checkFilter(checker, input.filter, '$.filter');
276
+ if (filter)
277
+ query.filter = filter;
278
+ }
279
+ }
280
+ if (Object.hasOwn(input, 'sort')) {
281
+ const sort = checkSort(checker, input.sort);
282
+ if (sort)
283
+ query.sort = sort;
284
+ }
285
+ if (Object.hasOwn(input, 'search')) {
286
+ const search = input.search;
287
+ if (!descriptor.operations.includes('search')) {
288
+ report(checker, 'search', '$.search', `"${descriptor.name}" does not search.`);
289
+ }
290
+ else if (typeof search !== 'string' || search.length > SOURCE_LIMITS.searchLength) {
291
+ report(checker, 'shape', '$.search', `search is text of at most ${SOURCE_LIMITS.searchLength} characters.`);
292
+ }
293
+ else if (search !== '') {
294
+ query.search = search;
295
+ }
296
+ }
297
+ if (Object.hasOwn(input, 'pageSize')) {
298
+ const size = input.pageSize;
299
+ if (typeof size !== 'number' || !Number.isInteger(size) || size < 1) {
300
+ report(checker, 'page-size', '$.pageSize', 'pageSize is a positive integer.');
301
+ }
302
+ else if (size > descriptor.maxPageSize) {
303
+ report(checker, 'page-size', '$.pageSize', `"${descriptor.name}" pages at most ${descriptor.maxPageSize} rows.`);
304
+ }
305
+ else {
306
+ query.pageSize = size;
307
+ }
308
+ }
309
+ if (checker.issues.length > 0)
310
+ return { ok: false, issues: checker.issues };
311
+ return { ok: true, query: Object.freeze(query) };
312
+ }
313
+ /**
314
+ * Canonical JSON of plain data: object keys sorted at every depth, arrays in
315
+ * order. Two equal queries give the same string, whatever their key order.
316
+ * Only call it on validated data (the depth is already bounded).
317
+ */
318
+ export function stableJson(value) {
319
+ if (value === undefined)
320
+ return 'null';
321
+ if (value === null || typeof value !== 'object')
322
+ return JSON.stringify(value) ?? 'null';
323
+ if (Array.isArray(value))
324
+ return `[${value.map((item) => stableJson(item)).join(',')}]`;
325
+ const keys = Object.keys(value)
326
+ .filter((key) => value[key] !== undefined)
327
+ .sort();
328
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
329
+ }
330
+ /** The cache and deduplication key of one page of one validated query. */
331
+ export function sourceQueryKey(source, query, page = {}) {
332
+ const projection = query.fields ? [...query.fields].sort() : undefined;
333
+ return stableJson({
334
+ source,
335
+ query: { ...query, ...(projection ? { fields: projection } : {}) },
336
+ cursor: page.cursor ?? null,
337
+ offset: page.offset ?? null,
338
+ });
339
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The host's source registry: the only place a blueprint's source name
3
+ * resolves. A source is a declaration plus an adapter the host writes; the
4
+ * adapter alone decides how to reach the data (memory, `fetch`, a client
5
+ * SDK). Texuli never builds a URL, SQL or code from a query, and agents
6
+ * never see an adapter, only {@link SourceDescriptor}s.
7
+ */
8
+ import type { SourceQuery, SourceValue } from './query.js';
9
+ import { type SourceDescriptor, type SourceSchemaDeclaration } from './schema.js';
10
+ /** A row's identity: the value of its key field. */
11
+ export type SourceRowId = string | number;
12
+ /** One row: field name → scalar. */
13
+ export type SourceRow = Readonly<Record<string, SourceValue>>;
14
+ /** Which page `list` asks for. `cursor` or `offset` follows the source's pagination style. */
15
+ export interface SourcePageRequest {
16
+ readonly size: number;
17
+ readonly cursor?: string;
18
+ readonly offset?: number;
19
+ }
20
+ export interface SourceListRequest {
21
+ /** The validated query (a frozen copy; never the agent's input). */
22
+ readonly query: SourceQuery;
23
+ readonly page: SourcePageRequest;
24
+ readonly signal: AbortSignal;
25
+ }
26
+ /** What an adapter's `list` resolves to. */
27
+ export interface SourcePage<Row = SourceRow> {
28
+ readonly rows: readonly Row[];
29
+ /** `cursor` sources: the next page's cursor, `null` or omitted at the end. */
30
+ readonly nextCursor?: string | null;
31
+ /** `offset` sources: whether rows follow. Default: a full page means maybe. */
32
+ readonly hasMore?: boolean;
33
+ /** The total row count, when the source knows it. */
34
+ readonly total?: number;
35
+ }
36
+ /**
37
+ * The host's code for one source. Each method backs the operation of the
38
+ * same name (`list` also backs `search`); a missing method for a declared
39
+ * operation is a declaration error. Every request carries an `AbortSignal`.
40
+ */
41
+ export interface SourceAdapter<Row = SourceRow> {
42
+ list?(request: SourceListRequest): Promise<SourcePage<Row>>;
43
+ get?(request: {
44
+ readonly id: SourceRowId;
45
+ readonly signal: AbortSignal;
46
+ }): Promise<Row | null>;
47
+ create?(request: {
48
+ readonly values: Readonly<Record<string, SourceValue>>;
49
+ readonly signal: AbortSignal;
50
+ }): Promise<Row>;
51
+ update?(request: {
52
+ readonly id: SourceRowId;
53
+ readonly changes: Readonly<Record<string, SourceValue>>;
54
+ readonly signal: AbortSignal;
55
+ }): Promise<Row>;
56
+ delete?(request: {
57
+ readonly id: SourceRowId;
58
+ readonly signal: AbortSignal;
59
+ }): Promise<void>;
60
+ }
61
+ /** A declared source: its descriptor and the host's adapter. */
62
+ export interface DataSource {
63
+ readonly descriptor: SourceDescriptor;
64
+ readonly adapter: SourceAdapter;
65
+ }
66
+ /**
67
+ * Declare a source. Throws a `TypeError` for a declaration mistake,
68
+ * including a declared operation without its adapter method.
69
+ */
70
+ export declare function defineSource(name: string, schema: SourceSchemaDeclaration, adapter: SourceAdapter): DataSource;
71
+ export interface SourceRegistry {
72
+ has(name: string): boolean;
73
+ get(name: string): DataSource | undefined;
74
+ /** Every registered name, sorted. */
75
+ names(): readonly string[];
76
+ /** One source's descriptor, or every descriptor (sorted by name): what validators and agents read. */
77
+ describe(name: string): SourceDescriptor | undefined;
78
+ describe(): readonly SourceDescriptor[];
79
+ /** Add a source; returns its removal. Throws on a taken name or past 256 sources. */
80
+ register(source: DataSource): () => void;
81
+ }
82
+ /** A registry seeded with `sources`. Lookups never reach the prototype chain. */
83
+ export declare function createSourceRegistry(sources?: readonly DataSource[]): SourceRegistry;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The host's source registry: the only place a blueprint's source name
3
+ * resolves. A source is a declaration plus an adapter the host writes; the
4
+ * adapter alone decides how to reach the data (memory, `fetch`, a client
5
+ * SDK). Texuli never builds a URL, SQL or code from a query, and agents
6
+ * never see an adapter, only {@link SourceDescriptor}s.
7
+ */
8
+ import { describeSource, SOURCE_LIMITS, } from "./schema.js";
9
+ const METHOD = Object.freeze({
10
+ list: 'list',
11
+ search: 'list',
12
+ get: 'get',
13
+ create: 'create',
14
+ update: 'update',
15
+ delete: 'delete',
16
+ });
17
+ /**
18
+ * Declare a source. Throws a `TypeError` for a declaration mistake,
19
+ * including a declared operation without its adapter method.
20
+ */
21
+ export function defineSource(name, schema, adapter) {
22
+ const descriptor = describeSource(name, schema);
23
+ for (const op of descriptor.operations) {
24
+ if (typeof adapter?.[METHOD[op]] !== 'function') {
25
+ throw new TypeError(`Data source "${name}": "${op}" needs adapter.${METHOD[op]}().`);
26
+ }
27
+ }
28
+ return Object.freeze({ descriptor, adapter });
29
+ }
30
+ /** A registry seeded with `sources`. Lookups never reach the prototype chain. */
31
+ export function createSourceRegistry(sources = []) {
32
+ const map = new Map();
33
+ const registry = {
34
+ has: (name) => typeof name === 'string' && map.has(name),
35
+ get: (name) => (typeof name === 'string' ? map.get(name) : undefined),
36
+ names: () => [...map.keys()].sort(),
37
+ describe: ((name) => {
38
+ if (name !== undefined)
39
+ return map.get(name)?.descriptor;
40
+ return Object.freeze([...map.values()]
41
+ .map((source) => source.descriptor)
42
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)));
43
+ }),
44
+ register(source) {
45
+ const name = source?.descriptor?.name;
46
+ if (typeof name !== 'string')
47
+ throw new TypeError('Register a source made by defineSource.');
48
+ if (map.has(name))
49
+ throw new TypeError(`Data source "${name}" is already registered.`);
50
+ if (map.size >= SOURCE_LIMITS.sources) {
51
+ throw new TypeError(`A registry holds at most ${SOURCE_LIMITS.sources} sources.`);
52
+ }
53
+ map.set(name, source);
54
+ return () => {
55
+ if (map.get(name) === source)
56
+ map.delete(name);
57
+ };
58
+ },
59
+ };
60
+ for (const source of sources)
61
+ registry.register(source);
62
+ return Object.freeze(registry);
63
+ }