@aglyn/plugins-marketing 1.0.0-beta.199 → 1.0.0-beta.201

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 (52) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/campaign-composer.js +14 -6
  3. package/src/lib/components/campaign-composer.js.map +1 -1
  4. package/src/lib/components/campaign-conversions-card.js +6 -0
  5. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  6. package/src/lib/components/campaign-detail-card.js +107 -37
  7. package/src/lib/components/campaign-detail-card.js.map +1 -1
  8. package/src/lib/components/campaign-members-section.d.ts +16 -4
  9. package/src/lib/components/campaign-members-section.js +171 -105
  10. package/src/lib/components/campaign-members-section.js.map +1 -1
  11. package/src/lib/components/campaign-queries.d.ts +11 -0
  12. package/src/lib/components/campaign-queries.js +13 -0
  13. package/src/lib/components/campaign-queries.js.map +1 -1
  14. package/src/lib/components/campaigns-card.d.ts +8 -6
  15. package/src/lib/components/campaigns-card.js +296 -128
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +4 -3
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +16 -5
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-recipients-card.js +3 -1
  22. package/src/lib/components/email-recipients-card.js.map +1 -1
  23. package/src/lib/components/emails-list-card.d.ts +11 -15
  24. package/src/lib/components/emails-list-card.js +142 -143
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +110 -78
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/org-experiments-card.js +7 -3
  29. package/src/lib/components/org-experiments-card.js.map +1 -1
  30. package/src/lib/model/campaign-list-query.d.ts +99 -0
  31. package/src/lib/model/campaign-list-query.js +373 -0
  32. package/src/lib/model/campaign-list-query.js.map +1 -0
  33. package/src/lib/model/campaign-members-query.d.ts +37 -0
  34. package/src/lib/model/campaign-members-query.js +120 -0
  35. package/src/lib/model/campaign-members-query.js.map +1 -0
  36. package/src/lib/model/experiment-list-query.d.ts +22 -0
  37. package/src/lib/model/experiment-list-query.js +85 -0
  38. package/src/lib/model/experiment-list-query.js.map +1 -0
  39. package/src/lib/server/campaign-form-flags.d.ts +35 -0
  40. package/src/lib/server/campaign-form-flags.js +52 -0
  41. package/src/lib/server/campaign-form-flags.js.map +1 -0
  42. package/src/lib/server/campaign-manage.d.ts +1 -1
  43. package/src/lib/server/campaign-manage.js +42 -20
  44. package/src/lib/server/campaign-manage.js.map +1 -1
  45. package/src/lib/server/campaign-send.d.ts +42 -1
  46. package/src/lib/server/campaign-send.js +247 -16
  47. package/src/lib/server/campaign-send.js.map +1 -1
  48. package/src/lib/server/email-events.js +13 -1
  49. package/src/lib/server/email-events.js.map +1 -1
  50. package/src/lib/components/email-send-filter.d.ts +0 -32
  51. package/src/lib/components/email-send-filter.js +0 -55
  52. package/src/lib/components/email-send-filter.js.map +0 -1
@@ -0,0 +1,373 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { nameSearchKey, nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
18
+ import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
19
+ /*
20
+ * WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).
21
+ *
22
+ * Three tables read the org's campaign collections — the Campaigns list, a
23
+ * campaign's own emails, and the Emails list — and every clause their
24
+ * Filters panels offer, and their search boxes, is a predicate on ONE query,
25
+ * newest first, paged by that query. Nothing is matched over the rows a read
26
+ * happened to hold. Each field is here because a query can serve it.
27
+ *
28
+ * ## The fields the writers stamp
29
+ *
30
+ * A SEND (`orgs/{orgId}/campaigns`) carries:
31
+ *
32
+ * `createdAtMs` when the record was minted — the one date on every
33
+ * send (a sent one has `sentAt`, a scheduled one
34
+ * `sendAtMs`, a draft neither), so the order;
35
+ * `subjectTokens` the subject's word prefixes, for search and
36
+ * "Subject contains";
37
+ * `emailCampaignId` the container it is filed under, or `null` for a
38
+ * single send — stored null rather than left out,
39
+ * because a query can ask for `== null` and can never
40
+ * ask for a field that is missing.
41
+ *
42
+ * A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,
43
+ * `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,
44
+ * as a prefix range — below).
45
+ *
46
+ * Every writer stamps them through the two helpers below, and
47
+ * `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records
48
+ * written before them.
49
+ *
50
+ * ## What a site hub asks
51
+ *
52
+ * A send is sent AS one site, so a site hub's sends are `hostId == {site}` —
53
+ * an equality, which merges with every other clause on single-field
54
+ * indexes and leaves the query's one array clause for search. The security
55
+ * rules admit that read for the site's collaborators (a send's `hostId` is
56
+ * the site its `visibleTo` names). A container is placed on SEVERAL sites,
57
+ * so its scope stays `visibleTo array-contains-any [org, host:{site}]` —
58
+ * the query's one array clause, and the only one the rules can prove for a
59
+ * site collaborator. So a site hub's campaign search is not a token lookup:
60
+ * it is a PREFIX RANGE on `nameLower` beside the scope
61
+ * (`campaignSiteSearchClause`), which orders the page by name while it
62
+ * applies, and the list says search matches the start of a name. Folding
63
+ * the word into the scope clause through scoped tokens would be refused by
64
+ * the rules for such a reader: that query proves nothing about `visibleTo`.
65
+ *
66
+ * ## What is not offered
67
+ *
68
+ * - A send's STATE (Sending, Stopped, …) is derived from its counters at
69
+ * read time (`campaignSendDisplay`); no query can ask it. Its stored
70
+ * `status` is offered instead.
71
+ * - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates
72
+ * against the clock; "running" is a range on two fields. Not offered.
73
+ * - The site NAME a row shows is not stored on the record; the Site filter
74
+ * asks by the site's id, and search reads the subject or the name only.
75
+ * - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so
76
+ * it needs no order of its own and no index beyond the ones above.
77
+ */ /** The send's subject, as word prefixes. */ export const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens';
78
+ /** The container's name, as word prefixes. */ export const CAMPAIGN_NAME_TOKENS = 'nameTokens';
79
+ /** The container's name, lower-cased whole, for a site hub's prefix search. */ export const CAMPAIGN_NAME_LOWER = 'nameLower';
80
+ /** The one date on every send and container, and every list's order. */ export const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs';
81
+ /**
82
+ * The Campaign filter's value for an email filed under no campaign. An
83
+ * empty value reads as "no value" to the panel, so the choice needs one of
84
+ * its own; the query asks `emailCampaignId == null` for it.
85
+ */ export const SINGLE_SEND_FILTER_VALUE = '__single__';
86
+ /**
87
+ * The search field a send carries beside its `subject`, stamped by every
88
+ * write that sets the subject.
89
+ */ export function campaignSendSearchFields(subject) {
90
+ return {
91
+ subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : '')
92
+ };
93
+ }
94
+ /**
95
+ * The search fields a container carries beside its `name`, stamped by every
96
+ * write that sets the name.
97
+ */ export function campaignContainerSearchFields(name) {
98
+ const text = typeof name === 'string' ? name : '';
99
+ return {
100
+ nameLower: nameSearchKey(text),
101
+ nameTokens: nameSearchTokens(text)
102
+ };
103
+ }
104
+ const ORDER = [
105
+ {
106
+ path: CAMPAIGN_LIST_ORDER_FIELD,
107
+ direction: 'desc'
108
+ }
109
+ ];
110
+ const CREATED = {
111
+ column: CAMPAIGN_LIST_ORDER_FIELD,
112
+ kind: 'date',
113
+ path: CAMPAIGN_LIST_ORDER_FIELD,
114
+ storedAs: 'millis',
115
+ operators: [
116
+ 'is',
117
+ 'after',
118
+ 'onOrAfter',
119
+ 'before',
120
+ 'onOrBefore'
121
+ ]
122
+ };
123
+ const SUBJECT = {
124
+ column: 'subject',
125
+ kind: 'text',
126
+ path: 'subject',
127
+ tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,
128
+ operators: [
129
+ 'contains'
130
+ ]
131
+ };
132
+ const STATUS = {
133
+ column: 'status',
134
+ kind: 'exact',
135
+ path: 'status',
136
+ operators: [
137
+ 'equals',
138
+ 'isAnyOf'
139
+ ]
140
+ };
141
+ /**
142
+ * The stored statuses a send can hold, for the Status filter. Stored
143
+ * strings, not the State column's display states: an email between batches
144
+ * is stored `scheduled` and SHOWN as Sending, which is why the filter is
145
+ * headed Status and not State.
146
+ */ export const CAMPAIGN_SEND_STATUS_OPTIONS = [
147
+ {
148
+ value: 'draft',
149
+ label: 'Draft'
150
+ },
151
+ {
152
+ value: 'scheduled',
153
+ label: 'Scheduled'
154
+ },
155
+ {
156
+ value: 'sending',
157
+ label: 'Sending'
158
+ },
159
+ {
160
+ value: 'sent',
161
+ label: 'Sent'
162
+ },
163
+ {
164
+ value: 'canceled',
165
+ label: 'Canceled'
166
+ },
167
+ {
168
+ value: 'failed',
169
+ label: 'Failed'
170
+ }
171
+ ];
172
+ /**
173
+ * The Emails list: every send, under a site or across the org.
174
+ *
175
+ * `site` is the org hub's only — under a site every row is that site's.
176
+ */ export function campaignEmailsListQuery(orgHub) {
177
+ return orgHub ? EMAILS_ORG : EMAILS_SITE;
178
+ }
179
+ const EMAIL_CAMPAIGN = {
180
+ column: 'emailCampaignId',
181
+ kind: 'exact',
182
+ path: 'emailCampaignId',
183
+ presence: 'nullable',
184
+ // `is` only: "Single send" is `== null`, which cannot sit in an `in`
185
+ // beside campaign ids.
186
+ operators: [
187
+ 'equals',
188
+ 'isEmpty'
189
+ ]
190
+ };
191
+ const EMAILS_SITE = {
192
+ fields: [
193
+ SUBJECT,
194
+ STATUS,
195
+ EMAIL_CAMPAIGN,
196
+ CREATED
197
+ ],
198
+ sorts: ORDER,
199
+ search: {
200
+ tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
201
+ }
202
+ };
203
+ const EMAILS_ORG = _extends({}, EMAILS_SITE, {
204
+ fields: [
205
+ ...EMAILS_SITE.fields,
206
+ {
207
+ column: 'site',
208
+ kind: 'exact',
209
+ path: 'hostId',
210
+ operators: [
211
+ 'equals',
212
+ 'isAnyOf'
213
+ ]
214
+ }
215
+ ]
216
+ });
217
+ /** A campaign's own emails: the container is the query's base. */ export const CAMPAIGN_EMAILS_QUERY = {
218
+ fields: [
219
+ SUBJECT,
220
+ STATUS,
221
+ CREATED
222
+ ],
223
+ sorts: ORDER,
224
+ search: {
225
+ tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
226
+ }
227
+ };
228
+ /**
229
+ * The Campaigns list's CAMPAIGNS: the containers. The columns are the
230
+ * table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org
231
+ * hub's only — under a site the scope clause is the query's one array
232
+ * clause, so an any-of on another array could never be served beside it.
233
+ */ export function campaignContainersListQuery(orgHub) {
234
+ return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE;
235
+ }
236
+ const CONTAINER_NAME = {
237
+ column: 'name',
238
+ kind: 'text',
239
+ path: 'name',
240
+ tokensPath: CAMPAIGN_NAME_TOKENS,
241
+ operators: [
242
+ 'contains'
243
+ ]
244
+ };
245
+ /** A site hub's Campaign field: the name's start, a range beside the scope. */ const CONTAINER_NAME_PREFIX = {
246
+ column: 'name',
247
+ kind: 'text',
248
+ path: 'name',
249
+ lowerPath: CAMPAIGN_NAME_LOWER,
250
+ operators: [
251
+ 'startsWith'
252
+ ]
253
+ };
254
+ const CONTAINERS_SITE = {
255
+ fields: [
256
+ CONTAINER_NAME_PREFIX,
257
+ CREATED
258
+ ],
259
+ sorts: ORDER
260
+ };
261
+ const CONTAINERS_ORG = {
262
+ sorts: ORDER,
263
+ search: {
264
+ tokensPath: CAMPAIGN_NAME_TOKENS
265
+ },
266
+ fields: [
267
+ CONTAINER_NAME,
268
+ {
269
+ column: 'listIds',
270
+ kind: 'exact',
271
+ path: 'listIds',
272
+ tokensPath: 'listIds',
273
+ operators: [
274
+ 'isAnyOf'
275
+ ]
276
+ },
277
+ // Asked by scope token — `org` is "every site", `host:{id}` one site.
278
+ {
279
+ column: 'sitesLabel',
280
+ kind: 'exact',
281
+ path: 'visibleTo',
282
+ tokensPath: 'visibleTo',
283
+ operators: [
284
+ 'isAnyOf'
285
+ ]
286
+ },
287
+ CREATED
288
+ ]
289
+ };
290
+ /**
291
+ * The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn
292
+ * in the same table. The `name` column is the send's subject; `sitesLabel`
293
+ * is the site it was sent as, on the org hub.
294
+ */ export function campaignSingleSendsListQuery(orgHub) {
295
+ return orgHub ? SINGLE_ORG : SINGLE_SITE;
296
+ }
297
+ const SINGLE_NAME = _extends({}, SUBJECT, {
298
+ column: 'name'
299
+ });
300
+ const SINGLE_SITE = {
301
+ fields: [
302
+ SINGLE_NAME,
303
+ CREATED
304
+ ],
305
+ sorts: ORDER,
306
+ search: {
307
+ tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS
308
+ }
309
+ };
310
+ const SINGLE_ORG = _extends({}, SINGLE_SITE, {
311
+ fields: [
312
+ SINGLE_NAME,
313
+ {
314
+ column: 'sitesLabel',
315
+ kind: 'exact',
316
+ path: 'hostId',
317
+ operators: [
318
+ 'equals',
319
+ 'isAnyOf'
320
+ ]
321
+ },
322
+ CREATED
323
+ ]
324
+ });
325
+ /** A site hub's sends: the ones sent as it. Nothing on the org hub. */ export function campaignSendsScope(hostId) {
326
+ return hostId ? [
327
+ {
328
+ path: 'hostId',
329
+ op: '==',
330
+ value: hostId
331
+ }
332
+ ] : [];
333
+ }
334
+ /** Said above a site hub's campaigns while its search is applied. */ export const CAMPAIGN_SITE_SEARCH_NOTICE = 'Search on a site’s campaigns matches the start of a campaign’s name.';
335
+ /**
336
+ * A site hub's campaign search, as the clause its query can serve: the typed
337
+ * words as "Campaign starts with", or null when nothing was typed. The list
338
+ * passes it among its clauses and no search words — see the file header for
339
+ * why a site's search is a prefix range and not a token lookup.
340
+ */ export function campaignSiteSearchClause(words) {
341
+ const typed = words.map((word)=>word.trim()).filter(Boolean).join(' ');
342
+ return typed ? {
343
+ field: 'name',
344
+ op: 'startsWith',
345
+ value: typed
346
+ } : null;
347
+ }
348
+ /** A site hub's containers: the ones placed on every site or on it. */ export function campaignContainersScope(hostId) {
349
+ return hostId ? [
350
+ {
351
+ path: 'visibleTo',
352
+ op: 'array-contains-any',
353
+ value: scopeTokensForHost(hostId)
354
+ }
355
+ ] : [];
356
+ }
357
+ /** The sends filed under no campaign. */ export const SINGLE_SENDS_BASE = {
358
+ path: 'emailCampaignId',
359
+ op: '==',
360
+ value: null
361
+ };
362
+ /**
363
+ * The Campaign filter's clauses as the query asks them: "Single send" is
364
+ * `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips
365
+ * keep reading the clause as the reader set it.
366
+ */ export function emailCampaignQueryClauses(clauses) {
367
+ return clauses.map((clause)=>clause.field === 'emailCampaignId' && clause.op === 'equals' && clause.value === SINGLE_SEND_FILTER_VALUE ? _extends({}, clause, {
368
+ op: 'isEmpty',
369
+ value: ''
370
+ }) : clause);
371
+ }
372
+
373
+ //# sourceMappingURL=campaign-list-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-list-query.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 { nameSearchKey, nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport type {\n ListQueryDeclaration,\n ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT THE MARKETING LISTS ASK FIRESTORE (AGL-3321).\n *\n * Three tables read the org's campaign collections — the Campaigns list, a\n * campaign's own emails, and the Emails list — and every clause their\n * Filters panels offer, and their search boxes, is a predicate on ONE query,\n * newest first, paged by that query. Nothing is matched over the rows a read\n * happened to hold. Each field is here because a query can serve it.\n *\n * ## The fields the writers stamp\n *\n * A SEND (`orgs/{orgId}/campaigns`) carries:\n *\n * `createdAtMs` when the record was minted — the one date on every\n * send (a sent one has `sentAt`, a scheduled one\n * `sendAtMs`, a draft neither), so the order;\n * `subjectTokens` the subject's word prefixes, for search and\n * \"Subject contains\";\n * `emailCampaignId` the container it is filed under, or `null` for a\n * single send — stored null rather than left out,\n * because a query can ask for `== null` and can never\n * ask for a field that is missing.\n *\n * A CONTAINER (`orgs/{orgId}/emailCampaigns`) carries `createdAtMs`,\n * `nameTokens` (the org hub's search) and `nameLower` (a site hub's search,\n * as a prefix range — below).\n *\n * Every writer stamps them through the two helpers below, and\n * `tools/scripts/backfill-campaign-list-fields.mjs` stamps the records\n * written before them.\n *\n * ## What a site hub asks\n *\n * A send is sent AS one site, so a site hub's sends are `hostId == {site}` —\n * an equality, which merges with every other clause on single-field\n * indexes and leaves the query's one array clause for search. The security\n * rules admit that read for the site's collaborators (a send's `hostId` is\n * the site its `visibleTo` names). A container is placed on SEVERAL sites,\n * so its scope stays `visibleTo array-contains-any [org, host:{site}]` —\n * the query's one array clause, and the only one the rules can prove for a\n * site collaborator. So a site hub's campaign search is not a token lookup:\n * it is a PREFIX RANGE on `nameLower` beside the scope\n * (`campaignSiteSearchClause`), which orders the page by name while it\n * applies, and the list says search matches the start of a name. Folding\n * the word into the scope clause through scoped tokens would be refused by\n * the rules for such a reader: that query proves nothing about `visibleTo`.\n *\n * ## What is not offered\n *\n * - A send's STATE (Sending, Stopped, …) is derived from its counters at\n * read time (`campaignSendDisplay`); no query can ask it. Its stored\n * `status` is offered instead.\n * - A campaign's WINDOW (Upcoming, Running, Ended) is derived from its dates\n * against the clock; \"running\" is a range on two fields. Not offered.\n * - The site NAME a row shows is not stored on the record; the Site filter\n * asks by the site's id, and search reads the subject or the name only.\n * - Ranges: ONE, on `createdAtMs`, the field every list is ordered by — so\n * it needs no order of its own and no index beyond the ones above.\n */\n\n/** The send's subject, as word prefixes. */\nexport const CAMPAIGN_SEND_SUBJECT_TOKENS = 'subjectTokens'\n/** The container's name, as word prefixes. */\nexport const CAMPAIGN_NAME_TOKENS = 'nameTokens'\n/** The container's name, lower-cased whole, for a site hub's prefix search. */\nexport const CAMPAIGN_NAME_LOWER = 'nameLower'\n/** The one date on every send and container, and every list's order. */\nexport const CAMPAIGN_LIST_ORDER_FIELD = 'createdAtMs'\n\n/**\n * The Campaign filter's value for an email filed under no campaign. An\n * empty value reads as \"no value\" to the panel, so the choice needs one of\n * its own; the query asks `emailCampaignId == null` for it.\n */\nexport const SINGLE_SEND_FILTER_VALUE = '__single__'\n\n/**\n * The search field a send carries beside its `subject`, stamped by every\n * write that sets the subject.\n */\nexport function campaignSendSearchFields(subject: unknown): {\n subjectTokens: string[]\n} {\n return {\n subjectTokens: nameSearchTokens(typeof subject === 'string' ? subject : ''),\n }\n}\n\n/**\n * The search fields a container carries beside its `name`, stamped by every\n * write that sets the name.\n */\nexport function campaignContainerSearchFields(name: unknown): {\n nameLower: string\n nameTokens: string[]\n} {\n const text = typeof name === 'string' ? name : ''\n return { nameLower: nameSearchKey(text), nameTokens: nameSearchTokens(text) }\n}\n\nconst ORDER = [{ path: CAMPAIGN_LIST_ORDER_FIELD, direction: 'desc' as const }]\n\nconst CREATED: ListFilterField = {\n column: CAMPAIGN_LIST_ORDER_FIELD,\n kind: 'date',\n path: CAMPAIGN_LIST_ORDER_FIELD,\n storedAs: 'millis',\n operators: ['is', 'after', 'onOrAfter', 'before', 'onOrBefore'],\n}\n\nconst SUBJECT: ListFilterField = {\n column: 'subject',\n kind: 'text',\n path: 'subject',\n tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS,\n operators: ['contains'],\n}\n\nconst STATUS: ListFilterField = {\n column: 'status',\n kind: 'exact',\n path: 'status',\n operators: ['equals', 'isAnyOf'],\n}\n\n/**\n * The stored statuses a send can hold, for the Status filter. Stored\n * strings, not the State column's display states: an email between batches\n * is stored `scheduled` and SHOWN as Sending, which is why the filter is\n * headed Status and not State.\n */\nexport const CAMPAIGN_SEND_STATUS_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [\n { value: 'draft', label: 'Draft' },\n { value: 'scheduled', label: 'Scheduled' },\n { value: 'sending', label: 'Sending' },\n { value: 'sent', label: 'Sent' },\n { value: 'canceled', label: 'Canceled' },\n { value: 'failed', label: 'Failed' },\n]\n\n/**\n * The Emails list: every send, under a site or across the org.\n *\n * `site` is the org hub's only — under a site every row is that site's.\n */\nexport function campaignEmailsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? EMAILS_ORG : EMAILS_SITE\n}\n\nconst EMAIL_CAMPAIGN: ListFilterField = {\n column: 'emailCampaignId',\n kind: 'exact',\n path: 'emailCampaignId',\n presence: 'nullable',\n // `is` only: \"Single send\" is `== null`, which cannot sit in an `in`\n // beside campaign ids.\n operators: ['equals', 'isEmpty'],\n}\n\nconst EMAILS_SITE: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, EMAIL_CAMPAIGN, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst EMAILS_ORG: ListQueryDeclaration = {\n ...EMAILS_SITE,\n fields: [\n ...EMAILS_SITE.fields,\n { column: 'site', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n ],\n}\n\n/** A campaign's own emails: the container is the query's base. */\nexport const CAMPAIGN_EMAILS_QUERY: ListQueryDeclaration = {\n fields: [SUBJECT, STATUS, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\n\n/**\n * The Campaigns list's CAMPAIGNS: the containers. The columns are the\n * table's (`name`, `listIds`, `sitesLabel`); Lists and Sites are the org\n * hub's only — under a site the scope clause is the query's one array\n * clause, so an any-of on another array could never be served beside it.\n */\nexport function campaignContainersListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? CONTAINERS_ORG : CONTAINERS_SITE\n}\n\nconst CONTAINER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n tokensPath: CAMPAIGN_NAME_TOKENS,\n operators: ['contains'],\n}\n/** A site hub's Campaign field: the name's start, a range beside the scope. */\nconst CONTAINER_NAME_PREFIX: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'name',\n lowerPath: CAMPAIGN_NAME_LOWER,\n operators: ['startsWith'],\n}\nconst CONTAINERS_SITE: ListQueryDeclaration = {\n fields: [CONTAINER_NAME_PREFIX, CREATED],\n sorts: ORDER,\n}\nconst CONTAINERS_ORG: ListQueryDeclaration = {\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_NAME_TOKENS },\n fields: [\n CONTAINER_NAME,\n { column: 'listIds', kind: 'exact', path: 'listIds', tokensPath: 'listIds', operators: ['isAnyOf'] },\n // Asked by scope token — `org` is \"every site\", `host:{id}` one site.\n { column: 'sitesLabel', kind: 'exact', path: 'visibleTo', tokensPath: 'visibleTo', operators: ['isAnyOf'] },\n CREATED,\n ],\n}\n\n/**\n * The Campaigns list's SINGLE SENDS: sends filed under no campaign, drawn\n * in the same table. The `name` column is the send's subject; `sitesLabel`\n * is the site it was sent as, on the org hub.\n */\nexport function campaignSingleSendsListQuery(orgHub: boolean): ListQueryDeclaration {\n return orgHub ? SINGLE_ORG : SINGLE_SITE\n}\n\nconst SINGLE_NAME: ListFilterField = { ...SUBJECT, column: 'name' }\nconst SINGLE_SITE: ListQueryDeclaration = {\n fields: [SINGLE_NAME, CREATED],\n sorts: ORDER,\n search: { tokensPath: CAMPAIGN_SEND_SUBJECT_TOKENS },\n}\nconst SINGLE_ORG: ListQueryDeclaration = {\n ...SINGLE_SITE,\n fields: [\n SINGLE_NAME,\n { column: 'sitesLabel', kind: 'exact', path: 'hostId', operators: ['equals', 'isAnyOf'] },\n CREATED,\n ],\n}\n\n/** A site hub's sends: the ones sent as it. Nothing on the org hub. */\nexport function campaignSendsScope(hostId: string | null): ListQueryFilter[] {\n return hostId ? [{ path: 'hostId', op: '==', value: hostId }] : []\n}\n\n/** Said above a site hub's campaigns while its search is applied. */\nexport const CAMPAIGN_SITE_SEARCH_NOTICE =\n 'Search on a site’s campaigns matches the start of a campaign’s name.'\n\n/**\n * A site hub's campaign search, as the clause its query can serve: the typed\n * words as \"Campaign starts with\", or null when nothing was typed. The list\n * passes it among its clauses and no search words — see the file header for\n * why a site's search is a prefix range and not a token lookup.\n */\nexport function campaignSiteSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words.map((word) => word.trim()).filter(Boolean).join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n\n/** A site hub's containers: the ones placed on every site or on it. */\nexport function campaignContainersScope(hostId: string | null): ListQueryFilter[] {\n return hostId\n ? [{ path: 'visibleTo', op: 'array-contains-any', value: scopeTokensForHost(hostId) }]\n : []\n}\n\n/** The sends filed under no campaign. */\nexport const SINGLE_SENDS_BASE: ListQueryFilter = {\n path: 'emailCampaignId',\n op: '==',\n value: null,\n}\n\n/**\n * The Campaign filter's clauses as the query asks them: \"Single send\" is\n * `emailCampaignId == null`, which the grammar spells `isEmpty`. The chips\n * keep reading the clause as the reader set it.\n */\nexport function emailCampaignQueryClauses(\n clauses: readonly ListFilterClause[],\n): ListFilterClause[] {\n return clauses.map((clause) =>\n clause.field === 'emailCampaignId' &&\n clause.op === 'equals' &&\n clause.value === SINGLE_SEND_FILTER_VALUE\n ? { ...clause, op: 'isEmpty', value: '' }\n : clause,\n )\n}\n"],"names":["nameSearchKey","nameSearchTokens","scopeTokensForHost","CAMPAIGN_SEND_SUBJECT_TOKENS","CAMPAIGN_NAME_TOKENS","CAMPAIGN_NAME_LOWER","CAMPAIGN_LIST_ORDER_FIELD","SINGLE_SEND_FILTER_VALUE","campaignSendSearchFields","subject","subjectTokens","campaignContainerSearchFields","name","text","nameLower","nameTokens","ORDER","path","direction","CREATED","column","kind","storedAs","operators","SUBJECT","tokensPath","STATUS","CAMPAIGN_SEND_STATUS_OPTIONS","value","label","campaignEmailsListQuery","orgHub","EMAILS_ORG","EMAILS_SITE","EMAIL_CAMPAIGN","presence","fields","sorts","search","CAMPAIGN_EMAILS_QUERY","campaignContainersListQuery","CONTAINERS_ORG","CONTAINERS_SITE","CONTAINER_NAME","CONTAINER_NAME_PREFIX","lowerPath","campaignSingleSendsListQuery","SINGLE_ORG","SINGLE_SITE","SINGLE_NAME","campaignSendsScope","hostId","op","CAMPAIGN_SITE_SEARCH_NOTICE","campaignSiteSearchClause","words","typed","map","word","trim","filter","Boolean","join","field","campaignContainersScope","SINGLE_SENDS_BASE","emailCampaignQueryClauses","clauses","clause"],"mappings":"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"}
@@ -0,0 +1,37 @@
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
+ import type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter';
18
+ import { type ListQueryDeclaration, type ListQueryFilter } from '@aglyn/shared-ui-jsx/const/list-query-plan';
19
+ /** A campaign's screens or forms: the name's start, walked by document name. */
20
+ export declare const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration;
21
+ /** The collections a campaign's members are read from. */
22
+ export type CampaignMemberCollection = 'screens' | 'forms';
23
+ /** A screen that has not been deleted: `deletedAt` stored as `null`. */
24
+ export declare const SCREEN_NOT_DELETED: ListQueryFilter;
25
+ /**
26
+ * The predicates every read of a campaign's members carries: the
27
+ * membership, and for screens the soft delete.
28
+ */
29
+ export declare function campaignMembersBase(collection: CampaignMemberCollection, campaignId: string): ListQueryFilter[];
30
+ /** Said above a member table while its search applies. */
31
+ export declare const CAMPAIGN_MEMBERS_SEARCH_NOTICE = "Search here matches the start of a name.";
32
+ /**
33
+ * The search box's words as the clause the query serves: "Name starts
34
+ * with", or null when nothing was typed. The table passes it among its
35
+ * clauses and no search words — see the file header for why.
36
+ */
37
+ export declare function campaignMembersSearchClause(words: readonly string[]): ListFilterClause | null;
@@ -0,0 +1,120 @@
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
+ */ import { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
17
+ import { LIST_QUERY_ID_PATH } from "@aglyn/shared-ui-jsx/const/list-query-plan";
18
+ /*
19
+ * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).
20
+ *
21
+ * A campaign's page lists the screens and the forms filed under it, each a
22
+ * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.
23
+ * The search box and the soft delete are predicates on that query, so a
24
+ * member on the fourth page is found and a deleted one is never a row.
25
+ *
26
+ * ## The base: the membership, and (for screens) not deleted
27
+ *
28
+ * `campaignIds array-contains {campaign}` is the join — the member's own
29
+ * field, read from the other end — and it is the query's ONE array clause.
30
+ *
31
+ * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the
32
+ * document, with its campaigns, in place. So the screens query also asks
33
+ * `deletedAt == null`, which matches only a document that HOLDS the field:
34
+ * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),
35
+ * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens
36
+ * written before it. A form has no soft delete — it is deleted outright —
37
+ * so its base is the membership alone, and a retired form stays a member.
38
+ *
39
+ * Unsearched, both lists walk the document name, which every record has:
40
+ * the equality and the array clause merge on Firestore's single-field
41
+ * indexes, so the default read needs no composite.
42
+ *
43
+ * ## The search is a PREFIX RANGE on `nameLower`
44
+ *
45
+ * A word search is an `array-contains` on `nameTokens`, and the membership
46
+ * has already spent the query's one array clause. So the search matches the
47
+ * START of the name — `nameLower` from the typed text to the text followed
48
+ * by the highest character — which is a range, orders the page by name while
49
+ * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),
50
+ * as a site's campaigns list says it.
51
+ *
52
+ * ## The composites: two
53
+ *
54
+ * A range beside equalities needs a composite. Under index merging this
55
+ * shape would take one `(predicate, nameLower)` pair per predicate — three
56
+ * across the two collections — so each collection instead carries the ONE
57
+ * composite that serves its whole search shape:
58
+ *
59
+ * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC
60
+ * forms campaignIds CONTAINS, nameLower ASC
61
+ *
62
+ * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column
63
+ * filter would be one more predicate beside the range, and so one more
64
+ * composite per collection, for lists that hold a campaign's handful of
65
+ * pages and forms.
66
+ */ /** The name field the search ranges over, as the planner reads it. */ const MEMBER_NAME = {
67
+ column: 'name',
68
+ kind: 'text',
69
+ path: 'displayName',
70
+ lowerPath: 'nameLower',
71
+ operators: [
72
+ 'startsWith'
73
+ ]
74
+ };
75
+ /** A campaign's screens or forms: the name's start, walked by document name. */ export const CAMPAIGN_MEMBERS_QUERY = {
76
+ fields: [
77
+ MEMBER_NAME
78
+ ],
79
+ sorts: [
80
+ {
81
+ path: LIST_QUERY_ID_PATH,
82
+ direction: 'asc'
83
+ }
84
+ ]
85
+ };
86
+ /** A screen that has not been deleted: `deletedAt` stored as `null`. */ export const SCREEN_NOT_DELETED = {
87
+ path: 'deletedAt',
88
+ op: '==',
89
+ value: null
90
+ };
91
+ /**
92
+ * The predicates every read of a campaign's members carries: the
93
+ * membership, and for screens the soft delete.
94
+ */ export function campaignMembersBase(collection, campaignId) {
95
+ return [
96
+ {
97
+ path: CAMPAIGN_MEMBERSHIP_FIELD,
98
+ op: 'array-contains',
99
+ value: campaignId
100
+ },
101
+ ...collection === 'screens' ? [
102
+ SCREEN_NOT_DELETED
103
+ ] : []
104
+ ];
105
+ }
106
+ /** Said above a member table while its search applies. */ export const CAMPAIGN_MEMBERS_SEARCH_NOTICE = 'Search here matches the start of a name.';
107
+ /**
108
+ * The search box's words as the clause the query serves: "Name starts
109
+ * with", or null when nothing was typed. The table passes it among its
110
+ * clauses and no search words — see the file header for why.
111
+ */ export function campaignMembersSearchClause(words) {
112
+ const typed = words.map((word)=>word.trim()).filter(Boolean).join(' ');
113
+ return typed ? {
114
+ field: 'name',
115
+ op: 'startsWith',
116
+ value: typed
117
+ } : null;
118
+ }
119
+
120
+ //# sourceMappingURL=campaign-members-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/campaign-members-query.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 { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type { ListFilterField } from '@aglyn/shared-ui-jsx/const/list-filter'\nimport type { ListFilterClause } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\n\n/*\n * WHAT A CAMPAIGN'S SCREENS AND FORMS LISTS ASK FIRESTORE (AGL-3321).\n *\n * A campaign's page lists the screens and the forms filed under it, each a\n * table paged by its own query over `hosts/{hostId}/screens` or `/forms`.\n * The search box and the soft delete are predicates on that query, so a\n * member on the fourth page is found and a deleted one is never a row.\n *\n * ## The base: the membership, and (for screens) not deleted\n *\n * `campaignIds array-contains {campaign}` is the join — the member's own\n * field, read from the other end — and it is the query's ONE array clause.\n *\n * A screen is soft-deleted: a delete stamps `deletedAt` and leaves the\n * document, with its campaigns, in place. So the screens query also asks\n * `deletedAt == null`, which matches only a document that HOLDS the field:\n * every screen create stamps `deletedAt: null` (`artifactCreateListKeys`),\n * and `tools/scripts/backfill-artifacts-list-keys.mjs` stamps the screens\n * written before it. A form has no soft delete — it is deleted outright —\n * so its base is the membership alone, and a retired form stays a member.\n *\n * Unsearched, both lists walk the document name, which every record has:\n * the equality and the array clause merge on Firestore's single-field\n * indexes, so the default read needs no composite.\n *\n * ## The search is a PREFIX RANGE on `nameLower`\n *\n * A word search is an `array-contains` on `nameTokens`, and the membership\n * has already spent the query's one array clause. So the search matches the\n * START of the name — `nameLower` from the typed text to the text followed\n * by the highest character — which is a range, orders the page by name while\n * it applies, and is said above the table (`CAMPAIGN_MEMBERS_SEARCH_NOTICE`),\n * as a site's campaigns list says it.\n *\n * ## The composites: two\n *\n * A range beside equalities needs a composite. Under index merging this\n * shape would take one `(predicate, nameLower)` pair per predicate — three\n * across the two collections — so each collection instead carries the ONE\n * composite that serves its whole search shape:\n *\n * screens campaignIds CONTAINS, deletedAt ASC, nameLower ASC\n * forms campaignIds CONTAINS, nameLower ASC\n *\n * Pinned by `list-query-indexes.spec.ts`. Nothing else is offered: a column\n * filter would be one more predicate beside the range, and so one more\n * composite per collection, for lists that hold a campaign's handful of\n * pages and forms.\n */\n\n/** The name field the search ranges over, as the planner reads it. */\nconst MEMBER_NAME: ListFilterField = {\n column: 'name',\n kind: 'text',\n path: 'displayName',\n lowerPath: 'nameLower',\n operators: ['startsWith'],\n}\n\n/** A campaign's screens or forms: the name's start, walked by document name. */\nexport const CAMPAIGN_MEMBERS_QUERY: ListQueryDeclaration = {\n fields: [MEMBER_NAME],\n sorts: [{ path: LIST_QUERY_ID_PATH, direction: 'asc' }],\n}\n\n/** The collections a campaign's members are read from. */\nexport type CampaignMemberCollection = 'screens' | 'forms'\n\n/** A screen that has not been deleted: `deletedAt` stored as `null`. */\nexport const SCREEN_NOT_DELETED: ListQueryFilter = {\n path: 'deletedAt',\n op: '==',\n value: null,\n}\n\n/**\n * The predicates every read of a campaign's members carries: the\n * membership, and for screens the soft delete.\n */\nexport function campaignMembersBase(\n collection: CampaignMemberCollection,\n campaignId: string,\n): ListQueryFilter[] {\n return [\n { path: CAMPAIGN_MEMBERSHIP_FIELD, op: 'array-contains', value: campaignId },\n ...(collection === 'screens' ? [SCREEN_NOT_DELETED] : []),\n ]\n}\n\n/** Said above a member table while its search applies. */\nexport const CAMPAIGN_MEMBERS_SEARCH_NOTICE =\n 'Search here matches the start of a name.'\n\n/**\n * The search box's words as the clause the query serves: \"Name starts\n * with\", or null when nothing was typed. The table passes it among its\n * clauses and no search words — see the file header for why.\n */\nexport function campaignMembersSearchClause(\n words: readonly string[],\n): ListFilterClause | null {\n const typed = words\n .map((word) => word.trim())\n .filter(Boolean)\n .join(' ')\n return typed ? { field: 'name', op: 'startsWith', value: typed } : null\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","LIST_QUERY_ID_PATH","MEMBER_NAME","column","kind","path","lowerPath","operators","CAMPAIGN_MEMBERS_QUERY","fields","sorts","direction","SCREEN_NOT_DELETED","op","value","campaignMembersBase","collection","campaignId","CAMPAIGN_MEMBERS_SEARCH_NOTICE","campaignMembersSearchClause","words","typed","map","word","trim","filter","Boolean","join","field"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAGtF,SACEC,kBAAkB,QAGb,6CAA4C;AAEnD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GAED,oEAAoE,GACpE,MAAMC,cAA+B;IACnCC,QAAQ;IACRC,MAAM;IACNC,MAAM;IACNC,WAAW;IACXC,WAAW;QAAC;KAAa;AAC3B;AAEA,8EAA8E,GAC9E,OAAO,MAAMC,yBAA+C;IAC1DC,QAAQ;QAACP;KAAY;IACrBQ,OAAO;QAAC;YAAEL,MAAMJ;YAAoBU,WAAW;QAAM;KAAE;AACzD,EAAC;AAKD,sEAAsE,GACtE,OAAO,MAAMC,qBAAsC;IACjDP,MAAM;IACNQ,IAAI;IACJC,OAAO;AACT,EAAC;AAED;;;CAGC,GACD,OAAO,SAASC,oBACdC,UAAoC,EACpCC,UAAkB;IAElB,OAAO;QACL;YAAEZ,MAAML;YAA2Ba,IAAI;YAAkBC,OAAOG;QAAW;WACvED,eAAe,YAAY;YAACJ;SAAmB,GAAG,EAAE;KACzD;AACH;AAEA,wDAAwD,GACxD,OAAO,MAAMM,iCACX,2CAA0C;AAE5C;;;;CAIC,GACD,OAAO,SAASC,4BACdC,KAAwB;IAExB,MAAMC,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKC,IAAI,IACvBC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,OAAON,QAAQ;QAAEO,OAAO;QAAQf,IAAI;QAAcC,OAAOO;IAAM,IAAI;AACrE"}
@@ -0,0 +1,22 @@
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
+ import type { ListQueryDeclaration } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
+ /** The array `contains` and the search box read: every word's prefixes. */
19
+ export declare const EXPERIMENT_NAME_TOKENS = "nameTokens";
20
+ /** The lower-cased whole name `equals` reads. */
21
+ export declare const EXPERIMENT_NAME_LOWER = "nameLower";
22
+ export declare const EXPERIMENT_LIST_QUERY: ListQueryDeclaration;