@aglyn/plugins-data 1.0.0-beta.199 → 1.0.0-beta.200

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-data",
3
- "version": "1.0.0-beta.199",
3
+ "version": "1.0.0-beta.200",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,14 +25,14 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.199",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.199",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.199",
31
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.199",
32
- "@aglyn/shared-util-http": "1.0.0-beta.199",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.199",
34
- "@aglyn/tenant-feature-instance": "1.0.0-beta.199",
35
- "@aglyn/tenant-runtime": "1.0.0-beta.199",
28
+ "@aglyn/aglyn": "1.0.0-beta.200",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.200",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.200",
31
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.200",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.200",
33
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.200",
34
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.200",
35
+ "@aglyn/tenant-runtime": "1.0.0-beta.200",
36
36
  "@swc/helpers": "0.5.23"
37
37
  },
38
38
  "peerDependencies": {
@@ -16,62 +16,58 @@
16
16
  */
17
17
  import { type DatasetModel } from '@aglyn/aglyn';
18
18
  import type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter';
19
- import { type ListFilterClause, type ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
20
- /**
21
- * How many records a filter the query cannot serve on its own is matched
22
- * over. Past it the card says the window was full; a single clause or a
23
- * single search word reaches every record without one.
24
- */
25
- export declare const RECORD_FILTER_WINDOW = 1000;
19
+ import type { ListFilterClause, ListFilterOption } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
20
+ import { type ListQueryDeclaration, type ListQueryNormalizers, type ListQueryPlan } from '@aglyn/shared-ui-jsx/const/list-query-plan';
26
21
  /**
27
22
  * The grid column a dataset field is shown in. Prefixed, because field ids
28
23
  * are the dataset author's and may be any name, `actions` included.
29
24
  */
30
25
  export declare const recordColumn: (fieldId: string) => string;
26
+ /**
27
+ * How the records' filter fields were normalized (`dataset-models.ts`): a
28
+ * typed text value becomes its `filterValues` key, and a search word its
29
+ * `s:` token — so the query asks for exactly what the writers stored.
30
+ */
31
+ export declare const datasetRecordNormalizers: ListQueryNormalizers;
31
32
  /** What the records grid's Filters panel offers for one dataset model. */
32
33
  export interface DatasetRecordFilter {
34
+ /** The query declaration: every field below, the id order, the search. */
35
+ declaration: ListQueryDeclaration;
33
36
  fields: ListFilterField[];
34
37
  options: Record<string, ListFilterOption[]>;
35
38
  headers: Record<string, string>;
36
39
  selectFields: string[];
37
40
  }
38
41
  /**
39
- * The model's fields as filter fields, each reading the record row at
40
- * `values.<fieldId>`: an enum is a select over its options, a boolean a
41
- * true/false select, numbers and timestamps number and date fields, a list a
42
- * member match, and plain text a text field. References, maps, bytes,
43
- * coordinates and nil fields offer no filter.
42
+ * The model's fields as query fields, each shown in column
43
+ * `values.<fieldId>`:
44
+ *
45
+ * enum text picked: is, is any of `filterValues.<id>`
46
+ * plain text contains (one word), equals, `filterKeys`,
47
+ * is any of (ignoring case) `filterValues.<id>`
48
+ * bool picked: is `filterValues.<id>`
49
+ * numbers = `filterValues.<id>`
50
+ * list contains (a whole member) `filterKeys`
51
+ *
52
+ * Timestamps, references, maps, bytes, coordinates and nil fields offer no
53
+ * filter. A field whose id no query path can name (a v1 column with a `.`
54
+ * in it — `datasetFilterValuePath`) offers only its `contains`.
44
55
  */
45
56
  export declare function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter;
46
- /** Which condition the query serves, and what is left to match in memory. */
47
- export interface RecordFilterPlan {
48
- /** The `filterKeys` token the query narrows by, or null for the plain walk. */
49
- token: string | null;
50
- /** The column of the served clause; null when a search word is served. */
51
- servedField: string | null;
52
- /** Whether anything is left for the window to match. */
53
- windowed: boolean;
54
- /** Every token each token-served clause needs, by clause index. */
55
- clauseTokens: Array<string[] | null>;
56
- /** Every quick-search token, one per word. */
57
- searchTokens: string[];
58
- clauses: readonly ListFilterClause[];
57
+ /** The records query, and what it says to the reader. */
58
+ export interface DatasetRecordPlan {
59
+ plan: ListQueryPlan;
60
+ /** What was not put on the query, as the reader asked it, and why. */
61
+ refused: Array<{
62
+ clause: ListFilterClause | 'search';
63
+ reason: string;
64
+ }>;
65
+ /** What WAS served, where it differs from what was typed. */
66
+ notices: string[];
59
67
  }
60
68
  /**
61
- * The served condition is the first clause a token answers, else the first
62
- * search word; the walk is windowed whenever anything else is in force.
63
- */
64
- export declare function planRecordFilter(model: DatasetModel, clauses: readonly ListFilterClause[], searchWords: readonly string[]): RecordFilterPlan;
65
- /**
66
- * The window's records that answer every condition.
67
- *
68
- * A token-served clause and the quick search are matched by the SAME tokens
69
- * the query uses, computed from the record's values under the current model,
70
- * so a record answers in memory exactly when it would answer on the server —
71
- * a text `contains` is a word-prefix match either way. Everything else
72
- * (ranges, dates, empty checks, negations, `isAnyOf`) is matched by the
73
- * shared grammar over `values.<fieldId>`.
69
+ * The clauses and search words as ONE records query (`planListQuery`), with
70
+ * the refusals mapped back to the clauses as the reader typed them, and the
71
+ * notices said in the reader's words rather than as stored tokens.
74
72
  */
75
- export declare function matchRecordWindow<Row extends {
76
- values?: Record<string, unknown>;
77
- }>(model: DatasetModel, fields: readonly ListFilterField[], rows: readonly Row[], plan: RecordFilterPlan): Row[];
73
+ export declare function planRecordQuery(model: DatasetModel, filter: DatasetRecordFilter, clauses: readonly ListFilterClause[], searchWords: readonly string[]): DatasetRecordPlan;
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -13,19 +14,65 @@
13
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
15
  * See the License for the specific language governing permissions and
15
16
  * limitations under the License.
16
- */ import { datasetFilterKeys, datasetFilterToken, datasetFilterTokens, datasetFilterWords, datasetSearchToken } from "@aglyn/aglyn";
17
- import { filterListRows, inMemoryListField } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
18
- /**
19
- * How many records a filter the query cannot serve on its own is matched
20
- * over. Past it the card says the window was full; a single clause or a
21
- * single search word reaches every record without one.
22
- */ export const RECORD_FILTER_WINDOW = 1000;
17
+ */ import { DATASET_FILTER_PREFIX_MAX, datasetFilterTextKey, datasetFilterToken, datasetFilterValuePath, datasetFilterWords, datasetSearchToken } from "@aglyn/aglyn";
18
+ import { LIST_QUERY_ID_PATH, planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
19
+ /*
20
+ * THE RECORDS TABLE IS ONE QUERY (AGL-3321).
21
+ *
22
+ * Every clause in the Filters panel and the quick-search word are
23
+ * predicates on the records query, planned by the shared `planListQuery`
24
+ * and answered by Firestore; nothing is matched over rows already loaded.
25
+ *
26
+ * A record's fields live under `values`, which is exempt from indexing (see
27
+ * `datasetFilterKeys`), so the query reads the two fields every writer
28
+ * stamps beside it:
29
+ *
30
+ * `filterValues.<fieldId> ==` an equality — a picked option, a
31
+ * boolean, a number, plain text (by its
32
+ * lower-cased key);
33
+ * `filterKeys array-contains` ONE word-level clause — a text
34
+ * `contains` (`f:<id>^<prefix>`), a list
35
+ * member (`f:<id>=<member>`), or the
36
+ * quick-search word (`s:<prefix>`).
37
+ *
38
+ * Firestore's automatic single-field indexes compose any number of those
39
+ * equalities and one `array-contains` under `orderBy(documentId())` by index
40
+ * merging, so the table needs NO composite index — which it could not have:
41
+ * every dataset's fields are its author's, and a composite per field per
42
+ * dataset is not something an index file can list.
43
+ *
44
+ * What one such query cannot hold is not offered, or refused by name:
45
+ *
46
+ * - no ranges. `>` on a number or a date on a timestamp is one inequality,
47
+ * which must then lead the order, and beside any equality it needs a
48
+ * composite per dataset. Numbers offer `=`, timestamps nothing.
49
+ * - no negations and no empty checks: Firestore has neither for a field a
50
+ * writer omits.
51
+ * - one array clause: a `contains` beside the search, or a second
52
+ * `contains`, is refused through `ListQueryNotices`, never half-applied.
53
+ * - one word of a `contains` or a search: the first word is asked, and the
54
+ * table says so for the rest.
55
+ */ /** The array the word-level clauses and the quick search ask. */ const TOKENS_PATH = 'filterKeys';
23
56
  /**
24
57
  * The grid column a dataset field is shown in. Prefixed, because field ids
25
58
  * are the dataset author's and may be any name, `actions` included.
26
59
  */ export const recordColumn = (fieldId)=>`values.${fieldId}`;
27
60
  const COLUMN_PREFIX = recordColumn('');
28
61
  const fieldIdOf = (column)=>column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column;
62
+ /**
63
+ * How the records' filter fields were normalized (`dataset-models.ts`): a
64
+ * typed text value becomes its `filterValues` key, and a search word its
65
+ * `s:` token — so the query asks for exactly what the writers stored.
66
+ */ export const datasetRecordNormalizers = {
67
+ key: datasetFilterTextKey,
68
+ token: (value)=>{
69
+ var _datasetSearchToken;
70
+ return (_datasetSearchToken = datasetSearchToken(value)) != null ? _datasetSearchToken : '';
71
+ },
72
+ // No field offers `endsWith`, so nothing is ever reversed.
73
+ reversed: (value)=>value,
74
+ maxPrefix: DATASET_FILTER_PREFIX_MAX
75
+ };
29
76
  const BOOLEAN_OPTIONS = [
30
77
  {
31
78
  value: 'true',
@@ -37,11 +84,19 @@ const BOOLEAN_OPTIONS = [
37
84
  }
38
85
  ];
39
86
  /**
40
- * The model's fields as filter fields, each reading the record row at
41
- * `values.<fieldId>`: an enum is a select over its options, a boolean a
42
- * true/false select, numbers and timestamps number and date fields, a list a
43
- * member match, and plain text a text field. References, maps, bytes,
44
- * coordinates and nil fields offer no filter.
87
+ * The model's fields as query fields, each shown in column
88
+ * `values.<fieldId>`:
89
+ *
90
+ * enum text picked: is, is any of `filterValues.<id>`
91
+ * plain text contains (one word), equals, `filterKeys`,
92
+ * is any of (ignoring case) `filterValues.<id>`
93
+ * bool picked: is `filterValues.<id>`
94
+ * numbers = `filterValues.<id>`
95
+ * list contains (a whole member) `filterKeys`
96
+ *
97
+ * Timestamps, references, maps, bytes, coordinates and nil fields offer no
98
+ * filter. A field whose id no query path can name (a v1 column with a `.`
99
+ * in it — `datasetFilterValuePath`) offers only its `contains`.
45
100
  */ export function datasetRecordFilter(model) {
46
101
  var _model_order;
47
102
  const fields = [];
@@ -54,35 +109,75 @@ const BOOLEAN_OPTIONS = [
54
109
  const field = (_model_fields = model.fields) == null ? void 0 : _model_fields[fieldId];
55
110
  if (!field) continue;
56
111
  const column = recordColumn(fieldId);
112
+ const valuePath = datasetFilterValuePath(fieldId);
57
113
  const choices = field.type === 'text' ? (_ref = (_field_validation = field.validation) == null ? void 0 : _field_validation.options) != null ? _ref : [] : [];
58
114
  let declared = null;
59
115
  if (field.type === 'text' && choices.length) {
60
- declared = inMemoryListField(column, 'select');
61
- options[column] = choices.map((choice)=>({
62
- value: choice,
63
- label: choice
64
- }));
116
+ if (valuePath) {
117
+ declared = {
118
+ column,
119
+ kind: 'exact',
120
+ path: valuePath,
121
+ operators: [
122
+ 'equals',
123
+ 'isAnyOf'
124
+ ]
125
+ };
126
+ options[column] = choices.map((choice)=>({
127
+ value: choice,
128
+ label: choice
129
+ }));
130
+ }
65
131
  } else if (field.type === 'text') {
66
- declared = inMemoryListField(column, 'text');
132
+ // A `contains` asks the token the clause's value is turned into
133
+ // (`recordQueryClause`), matched as given.
134
+ declared = _extends({
135
+ column,
136
+ kind: 'text',
137
+ path: valuePath != null ? valuePath : column,
138
+ tokensPath: TOKENS_PATH,
139
+ verbatimTokens: true
140
+ }, valuePath ? {
141
+ lowerPath: valuePath
142
+ } : {}, {
143
+ operators: valuePath ? [
144
+ 'contains',
145
+ 'equals',
146
+ 'isAnyOf'
147
+ ] : [
148
+ 'contains'
149
+ ]
150
+ });
67
151
  } else if (field.type === 'bool') {
68
- declared = inMemoryListField(column, 'select');
69
- options[column] = BOOLEAN_OPTIONS;
152
+ if (valuePath) {
153
+ declared = {
154
+ column,
155
+ kind: 'boolean',
156
+ path: valuePath,
157
+ operators: [
158
+ 'equals'
159
+ ]
160
+ };
161
+ options[column] = BOOLEAN_OPTIONS;
162
+ }
70
163
  } else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {
71
- declared = inMemoryListField(column, 'number');
72
- } else if (field.type === 'timestamp') {
73
- declared = inMemoryListField(column, 'date');
164
+ if (valuePath) declared = {
165
+ column,
166
+ kind: 'number',
167
+ path: valuePath,
168
+ operators: [
169
+ '='
170
+ ]
171
+ };
74
172
  } else if (field.type === 'sorted') {
75
- // A list matches by whole member, as its `=` tokens do.
76
173
  declared = {
77
174
  column,
78
- path: column,
79
175
  kind: 'text',
80
- tokensPath: column,
176
+ path: column,
177
+ tokensPath: TOKENS_PATH,
81
178
  verbatimTokens: true,
82
179
  operators: [
83
- 'contains',
84
- 'isEmpty',
85
- 'isNotEmpty'
180
+ 'contains'
86
181
  ]
87
182
  };
88
183
  }
@@ -92,63 +187,95 @@ const BOOLEAN_OPTIONS = [
92
187
  if (options[column]) selectFields.push(column);
93
188
  }
94
189
  return {
190
+ declaration: {
191
+ fields,
192
+ // The document name: the one order no record can be missing (see the
193
+ // card), and the one every automatic index already ends in.
194
+ sorts: [
195
+ {
196
+ path: LIST_QUERY_ID_PATH,
197
+ direction: 'asc'
198
+ }
199
+ ],
200
+ search: {
201
+ tokensPath: TOKENS_PATH
202
+ }
203
+ },
95
204
  fields,
96
205
  options,
97
206
  headers,
98
207
  selectFields
99
208
  };
100
209
  }
101
- const asFieldClause = (clause)=>({
102
- field: fieldIdOf(clause.field),
210
+ /**
211
+ * A clause as the query asks it: a `contains` becomes the `filterKeys`
212
+ * token of its first word (text) or of its member (list). Everything else
213
+ * goes as typed — the planner applies the text key, the number, the boolean.
214
+ */ function recordQueryClause(model, clause) {
215
+ if (clause.op !== 'contains') return {
216
+ field: clause.field,
103
217
  op: clause.op,
104
218
  value: clause.value
219
+ };
220
+ const token = datasetFilterToken(model, {
221
+ field: fieldIdOf(clause.field),
222
+ op: 'contains',
223
+ value: clause.value
105
224
  });
106
- /**
107
- * The served condition is the first clause a token answers, else the first
108
- * search word; the walk is windowed whenever anything else is in force.
109
- */ export function planRecordFilter(model, clauses, searchWords) {
110
- var _searchTokens_;
111
- var _clauseTokens_servedAt;
112
- const clauseTokens = clauses.map((clause)=>datasetFilterTokens(model, asFieldClause(clause)));
113
- const searchTokens = [
114
- ...new Set(searchWords.flatMap((word)=>datasetFilterWords(word)).map((word)=>datasetSearchToken(word)).filter((token)=>Boolean(token)))
115
- ];
116
- const servedAt = clauses.findIndex((clause)=>Boolean(datasetFilterToken(model, asFieldClause(clause))));
117
- const token = servedAt >= 0 ? datasetFilterToken(model, asFieldClause(clauses[servedAt])) : (_searchTokens_ = searchTokens[0]) != null ? _searchTokens_ : null;
118
- const complete = servedAt >= 0 ? clauses.length === 1 && searchTokens.length === 0 && ((_clauseTokens_servedAt = clauseTokens[servedAt]) == null ? void 0 : _clauseTokens_servedAt.length) === 1 : clauses.length === 0 && searchTokens.length <= 1;
119
225
  return {
120
- token,
121
- servedField: servedAt >= 0 ? clauses[servedAt].field : null,
122
- windowed: !complete,
123
- clauseTokens,
124
- searchTokens,
125
- clauses
226
+ field: clause.field,
227
+ op: clause.op,
228
+ value: token != null ? token : ''
126
229
  };
127
230
  }
231
+ const clipWord = (word)=>Array.from(word).slice(0, DATASET_FILTER_PREFIX_MAX).join('');
128
232
  /**
129
- * The window's records that answer every condition.
130
- *
131
- * A token-served clause and the quick search are matched by the SAME tokens
132
- * the query uses, computed from the record's values under the current model,
133
- * so a record answers in memory exactly when it would answer on the server —
134
- * a text `contains` is a word-prefix match either way. Everything else
135
- * (ranges, dates, empty checks, negations, `isAnyOf`) is matched by the
136
- * shared grammar over `values.<fieldId>`.
137
- */ export function matchRecordWindow(model, fields, rows, plan) {
138
- const served = new Set(plan.clauses.filter((_clause, at)=>plan.clauseTokens[at] !== null));
139
- const needed = [
140
- ...plan.clauseTokens.flatMap((tokens)=>tokens != null ? tokens : []),
141
- ...plan.searchTokens
142
- ];
143
- const rest = filterListRows(rows, fields, plan.clauses, {
144
- paths: [],
145
- words: []
146
- }, (clause)=>served.has(clause));
147
- if (!needed.length) return rest;
148
- return rest.filter((row)=>{
149
- const keys = new Set(datasetFilterKeys(model, row.values));
150
- return needed.every((token)=>keys.has(token));
233
+ * The clauses and search words as ONE records query (`planListQuery`), with
234
+ * the refusals mapped back to the clauses as the reader typed them, and the
235
+ * notices said in the reader's words rather than as stored tokens.
236
+ */ export function planRecordQuery(model, filter, clauses, searchWords) {
237
+ const asked = new Map();
238
+ const queryClauses = clauses.map((clause)=>{
239
+ const queryClause = recordQueryClause(model, clause);
240
+ asked.set(queryClause, clause);
241
+ return queryClause;
242
+ });
243
+ const plan = planListQuery(filter.declaration, {
244
+ clauses: queryClauses,
245
+ search: searchWords
246
+ }, datasetRecordNormalizers);
247
+ const refused = plan.refused.map((entry)=>{
248
+ var _asked_get;
249
+ return {
250
+ clause: entry.clause === 'search' ? 'search' : (_asked_get = asked.get(entry.clause)) != null ? _asked_get : entry.clause,
251
+ reason: entry.reason
252
+ };
151
253
  });
254
+ const notices = [];
255
+ const oneWord = (label, text)=>{
256
+ const words = datasetFilterWords(text);
257
+ if (words.length > 1) {
258
+ notices.push(`${label} matches one word at a time: showing records with a word starting "${clipWord(words[0])}".`);
259
+ }
260
+ if (words.some((word)=>Array.from(word).length > DATASET_FILTER_PREFIX_MAX)) {
261
+ notices.push(`${label} reads the first ${DATASET_FILTER_PREFIX_MAX} letters of a word.`);
262
+ }
263
+ };
264
+ if (plan.searched) oneWord('Search', searchWords.join(' '));
265
+ for (const served of plan.served){
266
+ var _filter_headers_clause_field;
267
+ var _model_fields_fieldIdOf, _model_fields;
268
+ const clause = asked.get(served);
269
+ // A list's `contains` is a whole member; only text is asked by word.
270
+ if ((clause == null ? void 0 : clause.op) !== 'contains') continue;
271
+ if (((_model_fields = model.fields) == null ? void 0 : (_model_fields_fieldIdOf = _model_fields[fieldIdOf(clause.field)]) == null ? void 0 : _model_fields_fieldIdOf.type) !== 'text') continue;
272
+ oneWord(`${(_filter_headers_clause_field = filter.headers[clause.field]) != null ? _filter_headers_clause_field : clause.field} contains`, clause.value);
273
+ }
274
+ return {
275
+ plan,
276
+ refused,
277
+ notices
278
+ };
152
279
  }
153
280
 
154
281
  //# sourceMappingURL=dataset-record-filter.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/components/dataset-record-filter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type DatasetModel,\n datasetFilterKeys,\n datasetFilterToken,\n datasetFilterTokens,\n datasetFilterWords,\n datasetSearchToken,\n} from '@aglyn/aglyn'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport {\n filterListRows,\n inMemoryListField,\n type ListFilterClause,\n type ListFilterOption,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\n\n/**\n * How many records a filter the query cannot serve on its own is matched\n * over. Past it the card says the window was full; a single clause or a\n * single search word reaches every record without one.\n */\nexport const RECORD_FILTER_WINDOW = 1000\n\n/**\n * The grid column a dataset field is shown in. Prefixed, because field ids\n * are the dataset author's and may be any name, `actions` included.\n */\nexport const recordColumn = (fieldId: string) => `values.${fieldId}`\nconst COLUMN_PREFIX = recordColumn('')\nconst fieldIdOf = (column: string) =>\n column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column\n\n/** What the records grid's Filters panel offers for one dataset model. */\nexport interface DatasetRecordFilter {\n fields: ListFilterField[]\n options: Record<string, ListFilterOption[]>\n headers: Record<string, string>\n selectFields: string[]\n}\n\nconst BOOLEAN_OPTIONS: ListFilterOption[] = [\n { value: 'true', label: 'True' },\n { value: 'false', label: 'False' },\n]\n\n/**\n * The model's fields as filter fields, each reading the record row at\n * `values.<fieldId>`: an enum is a select over its options, a boolean a\n * true/false select, numbers and timestamps number and date fields, a list a\n * member match, and plain text a text field. References, maps, bytes,\n * coordinates and nil fields offer no filter.\n */\nexport function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter {\n const fields: ListFilterField[] = []\n const options: Record<string, ListFilterOption[]> = {}\n const headers: Record<string, string> = {}\n const selectFields: string[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const column = recordColumn(fieldId)\n const choices = field.type === 'text' ? (field.validation?.options ?? []) : []\n let declared: ListFilterField | null = null\n if (field.type === 'text' && choices.length) {\n declared = inMemoryListField(column, 'select')\n options[column] = choices.map((choice) => ({ value: choice, label: choice }))\n } else if (field.type === 'text') {\n declared = inMemoryListField(column, 'text')\n } else if (field.type === 'bool') {\n declared = inMemoryListField(column, 'select')\n options[column] = BOOLEAN_OPTIONS\n } else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {\n declared = inMemoryListField(column, 'number')\n } else if (field.type === 'timestamp') {\n declared = inMemoryListField(column, 'date')\n } else if (field.type === 'sorted') {\n // A list matches by whole member, as its `=` tokens do.\n declared = {\n column,\n path: column,\n kind: 'text',\n tokensPath: column,\n verbatimTokens: true,\n operators: ['contains', 'isEmpty', 'isNotEmpty'],\n }\n }\n if (!declared) continue\n fields.push(declared)\n headers[column] = field.name || fieldId\n if (options[column]) selectFields.push(column)\n }\n return { fields, options, headers, selectFields }\n}\n\n/** Which condition the query serves, and what is left to match in memory. */\nexport interface RecordFilterPlan {\n /** The `filterKeys` token the query narrows by, or null for the plain walk. */\n token: string | null\n /** The column of the served clause; null when a search word is served. */\n servedField: string | null\n /** Whether anything is left for the window to match. */\n windowed: boolean\n /** Every token each token-served clause needs, by clause index. */\n clauseTokens: Array<string[] | null>\n /** Every quick-search token, one per word. */\n searchTokens: string[]\n clauses: readonly ListFilterClause[]\n}\n\nconst asFieldClause = (clause: ListFilterClause) => ({\n field: fieldIdOf(clause.field),\n op: clause.op,\n value: clause.value,\n})\n\n/**\n * The served condition is the first clause a token answers, else the first\n * search word; the walk is windowed whenever anything else is in force.\n */\nexport function planRecordFilter(\n model: DatasetModel,\n clauses: readonly ListFilterClause[],\n searchWords: readonly string[],\n): RecordFilterPlan {\n const clauseTokens = clauses.map((clause) =>\n datasetFilterTokens(model, asFieldClause(clause)),\n )\n const searchTokens = [\n ...new Set(\n searchWords\n .flatMap((word) => datasetFilterWords(word))\n .map((word) => datasetSearchToken(word))\n .filter((token): token is string => Boolean(token)),\n ),\n ]\n const servedAt = clauses.findIndex((clause) =>\n Boolean(datasetFilterToken(model, asFieldClause(clause))),\n )\n const token =\n servedAt >= 0\n ? datasetFilterToken(model, asFieldClause(clauses[servedAt]))\n : (searchTokens[0] ?? null)\n const complete =\n servedAt >= 0\n ? clauses.length === 1 &&\n searchTokens.length === 0 &&\n clauseTokens[servedAt]?.length === 1\n : clauses.length === 0 && searchTokens.length <= 1\n return {\n token,\n servedField: servedAt >= 0 ? clauses[servedAt].field : null,\n windowed: !complete,\n clauseTokens,\n searchTokens,\n clauses,\n }\n}\n\n/**\n * The window's records that answer every condition.\n *\n * A token-served clause and the quick search are matched by the SAME tokens\n * the query uses, computed from the record's values under the current model,\n * so a record answers in memory exactly when it would answer on the server —\n * a text `contains` is a word-prefix match either way. Everything else\n * (ranges, dates, empty checks, negations, `isAnyOf`) is matched by the\n * shared grammar over `values.<fieldId>`.\n */\nexport function matchRecordWindow<Row extends { values?: Record<string, unknown> }>(\n model: DatasetModel,\n fields: readonly ListFilterField[],\n rows: readonly Row[],\n plan: RecordFilterPlan,\n): Row[] {\n const served = new Set(\n plan.clauses.filter((_clause, at) => plan.clauseTokens[at] !== null),\n )\n const needed = [\n ...plan.clauseTokens.flatMap((tokens) => tokens ?? []),\n ...plan.searchTokens,\n ]\n const rest = filterListRows(\n rows,\n fields,\n plan.clauses,\n { paths: [], words: [] },\n (clause) => served.has(clause),\n )\n if (!needed.length) return rest\n return rest.filter((row) => {\n const keys = new Set(datasetFilterKeys(model, row.values))\n return needed.every((token) => keys.has(token))\n })\n}\n"],"names":["datasetFilterKeys","datasetFilterToken","datasetFilterTokens","datasetFilterWords","datasetSearchToken","filterListRows","inMemoryListField","RECORD_FILTER_WINDOW","recordColumn","fieldId","COLUMN_PREFIX","fieldIdOf","column","startsWith","slice","length","BOOLEAN_OPTIONS","value","label","datasetRecordFilter","model","fields","options","headers","selectFields","order","field","choices","type","validation","declared","map","choice","path","kind","tokensPath","verbatimTokens","operators","push","name","asFieldClause","clause","op","planRecordFilter","clauses","searchWords","searchTokens","clauseTokens","Set","flatMap","word","filter","token","Boolean","servedAt","findIndex","complete","servedField","windowed","matchRecordWindow","rows","plan","served","_clause","at","needed","tokens","rest","paths","words","has","row","keys","values","every"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/data/src/lib/components/dataset-record-filter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n DATASET_FILTER_PREFIX_MAX,\n type DatasetModel,\n datasetFilterTextKey,\n datasetFilterToken,\n datasetFilterValuePath,\n datasetFilterWords,\n datasetSearchToken,\n} from '@aglyn/aglyn'\nimport type { ListFilterField, ListFilterRequest } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type {\n ListFilterClause,\n ListFilterOption,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryNormalizers,\n type ListQueryPlan,\n planListQuery,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * THE RECORDS TABLE IS ONE QUERY (AGL-3321).\n *\n * Every clause in the Filters panel and the quick-search word are\n * predicates on the records query, planned by the shared `planListQuery`\n * and answered by Firestore; nothing is matched over rows already loaded.\n *\n * A record's fields live under `values`, which is exempt from indexing (see\n * `datasetFilterKeys`), so the query reads the two fields every writer\n * stamps beside it:\n *\n * `filterValues.<fieldId> ==` an equality — a picked option, a\n * boolean, a number, plain text (by its\n * lower-cased key);\n * `filterKeys array-contains` ONE word-level clause — a text\n * `contains` (`f:<id>^<prefix>`), a list\n * member (`f:<id>=<member>`), or the\n * quick-search word (`s:<prefix>`).\n *\n * Firestore's automatic single-field indexes compose any number of those\n * equalities and one `array-contains` under `orderBy(documentId())` by index\n * merging, so the table needs NO composite index — which it could not have:\n * every dataset's fields are its author's, and a composite per field per\n * dataset is not something an index file can list.\n *\n * What one such query cannot hold is not offered, or refused by name:\n *\n * - no ranges. `>` on a number or a date on a timestamp is one inequality,\n * which must then lead the order, and beside any equality it needs a\n * composite per dataset. Numbers offer `=`, timestamps nothing.\n * - no negations and no empty checks: Firestore has neither for a field a\n * writer omits.\n * - one array clause: a `contains` beside the search, or a second\n * `contains`, is refused through `ListQueryNotices`, never half-applied.\n * - one word of a `contains` or a search: the first word is asked, and the\n * table says so for the rest.\n */\n\n/** The array the word-level clauses and the quick search ask. */\nconst TOKENS_PATH = 'filterKeys'\n\n/**\n * The grid column a dataset field is shown in. Prefixed, because field ids\n * are the dataset author's and may be any name, `actions` included.\n */\nexport const recordColumn = (fieldId: string) => `values.${fieldId}`\nconst COLUMN_PREFIX = recordColumn('')\nconst fieldIdOf = (column: string) =>\n column.startsWith(COLUMN_PREFIX) ? column.slice(COLUMN_PREFIX.length) : column\n\n/**\n * How the records' filter fields were normalized (`dataset-models.ts`): a\n * typed text value becomes its `filterValues` key, and a search word its\n * `s:` token — so the query asks for exactly what the writers stored.\n */\nexport const datasetRecordNormalizers: ListQueryNormalizers = {\n key: datasetFilterTextKey,\n token: (value) => datasetSearchToken(value) ?? '',\n // No field offers `endsWith`, so nothing is ever reversed.\n reversed: (value) => value,\n maxPrefix: DATASET_FILTER_PREFIX_MAX,\n}\n\n/** What the records grid's Filters panel offers for one dataset model. */\nexport interface DatasetRecordFilter {\n /** The query declaration: every field below, the id order, the search. */\n declaration: ListQueryDeclaration\n fields: ListFilterField[]\n options: Record<string, ListFilterOption[]>\n headers: Record<string, string>\n selectFields: string[]\n}\n\nconst BOOLEAN_OPTIONS: ListFilterOption[] = [\n { value: 'true', label: 'True' },\n { value: 'false', label: 'False' },\n]\n\n/**\n * The model's fields as query fields, each shown in column\n * `values.<fieldId>`:\n *\n * enum text picked: is, is any of `filterValues.<id>`\n * plain text contains (one word), equals, `filterKeys`,\n * is any of (ignoring case) `filterValues.<id>`\n * bool picked: is `filterValues.<id>`\n * numbers = `filterValues.<id>`\n * list contains (a whole member) `filterKeys`\n *\n * Timestamps, references, maps, bytes, coordinates and nil fields offer no\n * filter. A field whose id no query path can name (a v1 column with a `.`\n * in it — `datasetFilterValuePath`) offers only its `contains`.\n */\nexport function datasetRecordFilter(model: DatasetModel): DatasetRecordFilter {\n const fields: ListFilterField[] = []\n const options: Record<string, ListFilterOption[]> = {}\n const headers: Record<string, string> = {}\n const selectFields: string[] = []\n for (const fieldId of model.order ?? []) {\n const field = model.fields?.[fieldId]\n if (!field) continue\n const column = recordColumn(fieldId)\n const valuePath = datasetFilterValuePath(fieldId)\n const choices = field.type === 'text' ? (field.validation?.options ?? []) : []\n let declared: ListFilterField | null = null\n if (field.type === 'text' && choices.length) {\n if (valuePath) {\n declared = { column, kind: 'exact', path: valuePath, operators: ['equals', 'isAnyOf'] }\n options[column] = choices.map((choice) => ({ value: choice, label: choice }))\n }\n } else if (field.type === 'text') {\n // A `contains` asks the token the clause's value is turned into\n // (`recordQueryClause`), matched as given.\n declared = {\n column,\n kind: 'text',\n path: valuePath ?? column,\n tokensPath: TOKENS_PATH,\n verbatimTokens: true,\n ...(valuePath ? { lowerPath: valuePath } : {}),\n operators: valuePath ? ['contains', 'equals', 'isAnyOf'] : ['contains'],\n }\n } else if (field.type === 'bool') {\n if (valuePath) {\n declared = { column, kind: 'boolean', path: valuePath, operators: ['equals'] }\n options[column] = BOOLEAN_OPTIONS\n }\n } else if (field.type === 'int32' || field.type === 'int64' || field.type === 'float') {\n if (valuePath) declared = { column, kind: 'number', path: valuePath, operators: ['='] }\n } else if (field.type === 'sorted') {\n declared = {\n column,\n kind: 'text',\n path: column,\n tokensPath: TOKENS_PATH,\n verbatimTokens: true,\n operators: ['contains'],\n }\n }\n if (!declared) continue\n fields.push(declared)\n headers[column] = field.name || fieldId\n if (options[column]) selectFields.push(column)\n }\n return {\n declaration: {\n fields,\n // The document name: the one order no record can be missing (see the\n // card), and the one every automatic index already ends in.\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n search: { tokensPath: TOKENS_PATH },\n },\n fields,\n options,\n headers,\n selectFields,\n }\n}\n\n/**\n * A clause as the query asks it: a `contains` becomes the `filterKeys`\n * token of its first word (text) or of its member (list). Everything else\n * goes as typed — the planner applies the text key, the number, the boolean.\n */\nfunction recordQueryClause(model: DatasetModel, clause: ListFilterClause): ListFilterRequest {\n if (clause.op !== 'contains') return { field: clause.field, op: clause.op, value: clause.value }\n const token = datasetFilterToken(model, {\n field: fieldIdOf(clause.field),\n op: 'contains',\n value: clause.value,\n })\n return { field: clause.field, op: clause.op, value: token ?? '' }\n}\n\nconst clipWord = (word: string) =>\n Array.from(word).slice(0, DATASET_FILTER_PREFIX_MAX).join('')\n\n/** The records query, and what it says to the reader. */\nexport interface DatasetRecordPlan {\n plan: ListQueryPlan\n /** What was not put on the query, as the reader asked it, and why. */\n refused: Array<{ clause: ListFilterClause | 'search'; reason: string }>\n /** What WAS served, where it differs from what was typed. */\n notices: string[]\n}\n\n/**\n * The clauses and search words as ONE records query (`planListQuery`), with\n * the refusals mapped back to the clauses as the reader typed them, and the\n * notices said in the reader's words rather than as stored tokens.\n */\nexport function planRecordQuery(\n model: DatasetModel,\n filter: DatasetRecordFilter,\n clauses: readonly ListFilterClause[],\n searchWords: readonly string[],\n): DatasetRecordPlan {\n const asked = new Map<ListFilterRequest, ListFilterClause>()\n const queryClauses = clauses.map((clause) => {\n const queryClause = recordQueryClause(model, clause)\n asked.set(queryClause, clause)\n return queryClause\n })\n const plan = planListQuery(\n filter.declaration,\n { clauses: queryClauses, search: searchWords },\n datasetRecordNormalizers,\n )\n const refused = plan.refused.map((entry) => ({\n clause: entry.clause === 'search' ? ('search' as const) : (asked.get(entry.clause) ?? entry.clause),\n reason: entry.reason,\n }))\n\n const notices: string[] = []\n const oneWord = (label: string, text: string) => {\n const words = datasetFilterWords(text)\n if (words.length > 1) {\n notices.push(\n `${label} matches one word at a time: showing records with a word starting \"${clipWord(words[0])}\".`,\n )\n }\n if (words.some((word) => Array.from(word).length > DATASET_FILTER_PREFIX_MAX)) {\n notices.push(`${label} reads the first ${DATASET_FILTER_PREFIX_MAX} letters of a word.`)\n }\n }\n if (plan.searched) oneWord('Search', searchWords.join(' '))\n for (const served of plan.served) {\n const clause = asked.get(served)\n // A list's `contains` is a whole member; only text is asked by word.\n if (clause?.op !== 'contains') continue\n if (model.fields?.[fieldIdOf(clause.field)]?.type !== 'text') continue\n oneWord(`${filter.headers[clause.field] ?? clause.field} contains`, clause.value)\n }\n return { plan, refused, notices }\n}\n"],"names":["DATASET_FILTER_PREFIX_MAX","datasetFilterTextKey","datasetFilterToken","datasetFilterValuePath","datasetFilterWords","datasetSearchToken","LIST_QUERY_ID_PATH","planListQuery","TOKENS_PATH","recordColumn","fieldId","COLUMN_PREFIX","fieldIdOf","column","startsWith","slice","length","datasetRecordNormalizers","key","token","value","reversed","maxPrefix","BOOLEAN_OPTIONS","label","datasetRecordFilter","model","fields","options","headers","selectFields","order","field","valuePath","choices","type","validation","declared","kind","path","operators","map","choice","tokensPath","verbatimTokens","lowerPath","push","name","declaration","sorts","direction","search","recordQueryClause","clause","op","clipWord","word","Array","from","join","planRecordQuery","filter","clauses","searchWords","asked","Map","queryClauses","queryClause","set","plan","refused","entry","get","reason","notices","oneWord","text","words","some","searched","served"],"mappings":"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"}