@aglyn/plugins-data 1.0.0-beta.233 → 1.0.0-beta.235

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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The pure half of the dataset model (AGL-3668): the model and field types,
19
+ * the v1 shim, the display and input forms of a stored value, and the
20
+ * records table filter grammar (`filterKeys`/`filterValues` and the tokens a
21
+ * clause or a search word asks for). No imports, so the native apps generate
22
+ * from it (tools/scripts/mobile-pure-modules.json) and replay its answers.
23
+ * `dataset-models.ts` re-exports all of it beside the validation and
24
+ * coercion that reach the plugin registry.
25
+ */
26
+ /**
27
+ * A human field entry from the quick creator: the stable id plus the
28
+ * display name shown in table headers and bindings pickers (AGL-558).
29
+ */
30
+ export interface DatasetFieldEntry {
31
+ id: string;
32
+ name: string;
33
+ }
34
+ /** Display fallback for raw ids: "roast_preference" → "Roast preference". */
35
+ export declare function humanizeDatasetFieldId(id: string): string;
36
+ /** dod.ts `FT.Tag` vocabulary plus `reference` (AGL-180 builds on it). */
37
+ export type DatasetFieldType = 'bool' | 'bytes' | 'timestamp' | 'float' | 'int32' | 'int64' | 'nil' | 'text' | 'coordinates' | 'map' | 'sorted' | 'reference';
38
+ export declare const DATASET_FIELD_TYPES: DatasetFieldType[];
39
+ /**
40
+ * The types a person picks for a new field in the Schema dialog; the rest
41
+ * exist for compatibility and stay editable on a field that already has one.
42
+ */
43
+ export declare const DATASET_AUTHORABLE_FIELD_TYPES: DatasetFieldType[];
44
+ /** Display names, mirroring dod.ts `lbl`. */
45
+ export declare const DATASET_FIELD_TYPE_LABELS: Record<DatasetFieldType, string>;
46
+ /**
47
+ * Plain-JSON validation vocabulary (the dod.ts `Eval` shape, extended
48
+ * with min/max and options, serialized flat instead of enum-keyed).
49
+ */
50
+ export interface DatasetFieldValidation {
51
+ required?: boolean;
52
+ /** Source of a RegExp applied to text values. */
53
+ regex?: string;
54
+ /** Numbers/timestamps: value bound. Text: length bound. */
55
+ min?: number;
56
+ max?: number;
57
+ /** Enum: value must be one of these (text fields). */
58
+ options?: string[];
59
+ }
60
+ export interface DatasetFieldDefinition {
61
+ /**
62
+ * Human-facing display label only. The stable reference id is the KEY in
63
+ * `DatasetModel.fields` (and `order`), NOT this name — bindings resolve
64
+ * `{{item.<fieldId>}}` against that key. The default id is derived from
65
+ * this name once at creation but is user-overridable (AGL-578); renaming
66
+ * the label afterwards never changes the id.
67
+ */
68
+ name: string;
69
+ type: DatasetFieldType;
70
+ /**
71
+ * Plugin-declared custom type riding this base type (AGL-434) — see
72
+ * plugin-manager/custom-fields. Unknown names degrade to the base type.
73
+ */
74
+ customType?: string;
75
+ /**
76
+ * A page-address field's source (AGL-3475): the field an EMPTY address
77
+ * fills in from, once — see `fillRecordAddresses` in `record-pages`.
78
+ * Meaningful only on a field of the page-address custom type.
79
+ */
80
+ slugFrom?: string;
81
+ description?: string;
82
+ required?: boolean;
83
+ /** Default applied by editors when creating documents; never coerced. */
84
+ default?: unknown;
85
+ validation?: DatasetFieldValidation;
86
+ /**
87
+ * Reference fields (AGL-180): target collection + display field id.
88
+ * `multiple` stores a `sorted` array of FKeys (small-cardinality
89
+ * many-to-many per the dod.ts guidance); `onDelete` is the referenced-
90
+ * document delete policy (default `setNull` — never cascade in v2).
91
+ */
92
+ reference?: {
93
+ datasetId: string;
94
+ displayFieldId?: string;
95
+ multiple?: boolean;
96
+ onDelete?: 'restrict' | 'setNull';
97
+ };
98
+ }
99
+ export interface DatasetModel {
100
+ fields: Record<string, DatasetFieldDefinition>;
101
+ /** Field ids in display order. */
102
+ order: string[];
103
+ }
104
+ /**
105
+ * Migration shim (v1 → v2): datasets created before models carry flat
106
+ * `fields: string[]` — derive a model where every column is an optional
107
+ * text field whose id is the column name, so the editor and AGL-103
108
+ * bindings keep working on unmigrated docs during rollout.
109
+ */
110
+ export declare function deriveModelFromFields(fields: string[]): DatasetModel;
111
+ /**
112
+ * Model from quick-creator entries (AGL-558): stable slug ids with the
113
+ * human display names preserved — "Roast preference" → id
114
+ * `roast_preference`, name "Roast preference".
115
+ */
116
+ export declare function modelFromFieldEntries(entries: readonly DatasetFieldEntry[]): DatasetModel;
117
+ /** The dataset's model, deriving one from v1 `fields` when absent. */
118
+ export declare function effectiveDatasetModel(dataset: {
119
+ model?: DatasetModel;
120
+ fields?: string[];
121
+ }): DatasetModel;
122
+ /** Storage value → grid display string (AGL-179). */
123
+ export declare function formatDatasetValue(field: DatasetFieldDefinition, value: unknown): string;
124
+ /**
125
+ * Storage value → form-input string (AGL-179): the inverse of
126
+ * `coerceDocumentValues` for populating type-appropriate inputs
127
+ * (timestamps as `datetime-local` values, coordinates as "lat, lon").
128
+ */
129
+ export declare function datasetValueToInput(field: DatasetFieldDefinition, value: unknown): string;
130
+ /**
131
+ * A dataset record's FILTER TOKENS: every word-level question the records
132
+ * table can ask of a record, flattened into one array of strings Firestore
133
+ * indexes on its own. Its twin, {@link datasetFilterValues}, holds the
134
+ * equalities.
135
+ *
136
+ * ## Why these fields, and not the values
137
+ *
138
+ * A record's fields live under `values`, a user-defined, unbounded map that
139
+ * the index configuration exempts from indexing — auto-indexing it can exceed
140
+ * Firestore's per-document index-entry limit — so `where('values.<field>', …)`
141
+ * is refused. A composite index per dataset would need an admin call per
142
+ * dataset, runs into the project's composite-index cap, and is deleted by the
143
+ * next `firestore:indexes` deploy of a file that does not list it.
144
+ *
145
+ * `filterKeys` and `filterValues` need none of that. Firestore's AUTOMATIC
146
+ * single-field indexes serve `where('filterKeys', 'array-contains', token)`
147
+ * and `where('filterValues.<field>', '==', value)`, and every automatic index
148
+ * ends in the document name — so any number of those equalities and ONE
149
+ * `array-contains`, under `orderBy(documentId())`, compose by index merging,
150
+ * with no composite index and no index deploy, on any dataset of any project
151
+ * (AGL-3321).
152
+ *
153
+ * ## The token grammar
154
+ *
155
+ * `f:<fieldId>=<value>` a field equals a value. Enum (a text field with
156
+ * `validation.options`) values are kept exactly;
157
+ * plain text is lower-cased, trimmed and clipped to
158
+ * {@link DATASET_FILTER_VALUE_MAX} characters;
159
+ * booleans are `true`/`false`; numbers are
160
+ * `String(number)`; a `sorted` list contributes one
161
+ * token per member.
162
+ * `f:<fieldId>^<prefix>` a word of a plain text field starts with this.
163
+ * `s:<prefix>` a word of ANY text value (enums and list members
164
+ * included) starts with this — the quick search.
165
+ *
166
+ * Timestamps, references, maps, bytes, coordinates and nil fields carry no
167
+ * field tokens: a timestamp is asked in ranges, which one token cannot serve,
168
+ * and the rest are not asked about by value.
169
+ *
170
+ * The array is sorted, de-duplicated and capped at
171
+ * {@link DATASET_FILTER_KEYS_MAX}. Past the cap, the `=` tokens are kept
172
+ * first, then the per-field word prefixes, then the search prefixes, so a
173
+ * very long record loses search reach before it loses exact matches.
174
+ */
175
+ export declare const DATASET_FILTER_KEYS_MAX = 500;
176
+ /** Plain text values are clipped to this many characters in an `=` token. */
177
+ export declare const DATASET_FILTER_VALUE_MAX = 64;
178
+ /** Word prefixes run from 1 to this many characters. */
179
+ export declare const DATASET_FILTER_PREFIX_MAX = 12;
180
+ /** Only the first this-many words of a value contribute prefixes. */
181
+ export declare const DATASET_FILTER_WORDS_MAX = 40;
182
+ /** One clause of the records table's filter: `{ field, op, value }`. */
183
+ export interface DatasetFilterClause {
184
+ field: string;
185
+ op: string;
186
+ value: string;
187
+ }
188
+ /**
189
+ * The words of a text value: split on anything that is not a letter or a
190
+ * digit, lower-cased, at most {@link DATASET_FILTER_WORDS_MAX} of them.
191
+ */
192
+ export declare function datasetFilterWords(text: string): string[];
193
+ /**
194
+ * Plain text as its stored key — its `=` token's value and its
195
+ * `filterValues` entry: lower-cased, trimmed, clipped to
196
+ * {@link DATASET_FILTER_VALUE_MAX} characters. What the records table asks a
197
+ * typed value to equal.
198
+ */
199
+ export declare function datasetFilterTextKey(value: string): string;
200
+ /** The tokens described in {@link DATASET_FILTER_KEYS_MAX}'s comment. */
201
+ export declare function datasetFilterKeys(model: DatasetModel, values: Record<string, unknown> | undefined): string[];
202
+ /** A record's `filterValues`: field id → the value an equality asks for. */
203
+ export type DatasetFilterValues = Record<string, string | number | boolean>;
204
+ /**
205
+ * The query path of a field's `filterValues` entry, or null for a field id
206
+ * no path can name — such a field is stamped no entry, and the records table
207
+ * offers it no equality (its `contains`, served by `filterKeys`, stands).
208
+ */
209
+ export declare function datasetFilterValuePath(fieldId: string): string | null;
210
+ /**
211
+ * A dataset record's FILTER VALUES: `{ [fieldId]: value }`, one scalar per
212
+ * field an equality can be asked of, so the records table puts every
213
+ * equality on its query (AGL-3321).
214
+ *
215
+ * enum text (`validation.options`) the value exactly, as its `=` token;
216
+ * plain text {@link datasetFilterTextKey};
217
+ * bool a boolean;
218
+ * int32 / int64 / float a number.
219
+ *
220
+ * A form or an automation stores every value as text, so `'19.50'` is the
221
+ * number 19.5 and `'true'` the boolean, as in {@link datasetFilterKeys}.
222
+ * Fields with no value, and timestamps, lists, references, maps, bytes,
223
+ * coordinates and nil fields, carry no entry: a list is asked by member
224
+ * (`f:<id>=<member>` in `filterKeys`), and the rest are not asked about by
225
+ * value.
226
+ *
227
+ * ## What it costs
228
+ *
229
+ * Unlike `values`, this map is NOT exempt from indexing — that is its whole
230
+ * point. Each entry is a scalar and costs two automatic index entries
231
+ * (ascending, descending), so a record costs at most two per model field —
232
+ * bounded by the model's field count (the `/v1` API caps a model at 100
233
+ * fields), far inside Firestore's 40,000 entries per document, where
234
+ * `values` was unbounded because a `map` or `sorted` value may hold anything.
235
+ */
236
+ export declare function datasetFilterValues(model: DatasetModel, values: Record<string, unknown> | undefined): DatasetFilterValues;
237
+ /**
238
+ * The `filterKeys` token a word-level clause asks for, or null when it is
239
+ * not one:
240
+ *
241
+ * - plain text `contains` → the `^` token of the value's FIRST word, so a
242
+ * `contains` is a word-prefix match, not a mid-string one, and one word
243
+ * of several is asked (the records table says so);
244
+ * - list `contains` (and `equals`) → the member's `=` token;
245
+ * - enum, boolean, number and plain text `equals` → the `=` token of the
246
+ * value. The records table asks these of `filterValues` instead, which
247
+ * composes with a `contains`; the token remains what the quick search and
248
+ * older readers match.
249
+ * - everything else — ranges, dates, `isEmpty`, `isAnyOf`, negations — null.
250
+ *
251
+ * `clause.field` is the field id. The operator names are the grid's: a
252
+ * select's `equals`, a number's `=`, a boolean's `is`, a list's `contains`.
253
+ */
254
+ export declare function datasetFilterToken(model: DatasetModel, clause: DatasetFilterClause): string | null;
255
+ /**
256
+ * The quick-search token for one typed word, or null for a word with no
257
+ * letters or digits. A word with punctuation inside it is asked by its first
258
+ * run — split the search text with {@link datasetFilterWords} to ask for
259
+ * every run.
260
+ */
261
+ export declare function datasetSearchToken(word: string): string | null;
262
+ /** Dataset field name: starts with a letter; letters/digits/underscores. */
263
+ export declare const DATASET_FIELD_PATTERN: RegExp;
264
+ /**
265
+ * Stable field id from a human name: "Roast preference" → "roast_preference".
266
+ * Mirrors DATASET_FIELD_PATTERN; returns '' when nothing salvageable.
267
+ */
268
+ export declare function slugifyDatasetFieldId(name: string): string;
269
+ /**
270
+ * Default reference id for a new field (AGL-578): the `slugifyDatasetFieldId`
271
+ * slug, suffixed (`_2`, `_3`, …) to stay unique within the dataset. `taken`
272
+ * holds the ids already in use (compared case-insensitively). Returns '' when
273
+ * the name yields nothing valid. The user can override the result.
274
+ */
275
+ export declare function defaultDatasetFieldId(name: string, taken: ReadonlySet<string>): string;
276
+ /**
277
+ * Validate a user-entered reference id (AGL-578): non-empty, matches
278
+ * `DATASET_FIELD_PATTERN`, and unique within the dataset (`taken` = ids already
279
+ * used, compared case-insensitively). Returns an error message, or null when
280
+ * the id is usable.
281
+ */
282
+ export declare function validateDatasetFieldId(id: string, taken: ReadonlySet<string>): string | null;
283
+ /**
284
+ * Parses a comma/newline separated list of HUMAN field names into
285
+ * {id, name} entries — "Roast preference" keeps its pretty name and gets
286
+ * the stable id `roast_preference` (AGL-558). Plain snake_case keys
287
+ * still work and pick up a humanized display name. Duplicate ids and
288
+ * unsalvageable entries are dropped rather than failing the set.
289
+ */
290
+ export declare function parseDatasetFieldEntries(input: string): DatasetFieldEntry[];
291
+ /**
292
+ * The name a dataset is shown under: `displayName`, which is what every create
293
+ * path writes (AGL-536), then the pre-migration `name`. Blank when the document
294
+ * carries neither.
295
+ */
296
+ export declare function datasetDisplayName(dataset: {
297
+ displayName?: unknown;
298
+ name?: unknown;
299
+ } | null | undefined): string;