@aglyn/tenant-feature-instance 1.0.0-beta.197 → 1.0.0-beta.199

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/tenant-feature-instance",
3
- "version": "1.0.0-beta.197",
3
+ "version": "1.0.0-beta.199",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.197",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.197",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.197",
31
- "@aglyn/shared-util-http": "1.0.0-beta.197",
32
- "@aglyn/shared-util-timestamp": "1.0.0-beta.197",
28
+ "@aglyn/aglyn": "1.0.0-beta.199",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.199",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.199",
31
+ "@aglyn/shared-util-http": "1.0.0-beta.199",
32
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.199",
33
33
  "@swc/helpers": "0.5.23",
34
34
  "lodash-es": "^4.18.1"
35
35
  },
@@ -75,5 +75,8 @@ export interface ListFilterPlan {
75
75
  * First SERVABLE, not first listed: a reader who adds an owner clause (a
76
76
  * facet field no query reaches) and then a name prefix should have the
77
77
  * name searched, not the owner "served" as nothing.
78
- */
78
+ * @deprecated AGL-3321: matches over rows a list has loaded. Serve every clause and search
79
+ * word on the query instead — `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`, with `nameSearchNormalizers`)
80
+ * through `useListQuery` or `applyListQuery`. Removed once no list calls it.
81
+ */
79
82
  export declare function listFilterPlan(fields: readonly ListFilterField[], clauses: readonly ListFilterRequest[], options?: ListFilterConstraintOptions): ListFilterPlan;
@@ -279,7 +279,10 @@ const toDay = (value)=>{
279
279
  * First SERVABLE, not first listed: a reader who adds an owner clause (a
280
280
  * facet field no query reaches) and then a name prefix should have the
281
281
  * name searched, not the owner "served" as nothing.
282
- */ export function listFilterPlan(fields, clauses, options = {}) {
282
+ * @deprecated AGL-3321: matches over rows a list has loaded. Serve every clause and search
283
+ * word on the query instead — `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`, with `nameSearchNormalizers`)
284
+ * through `useListQuery` or `applyListQuery`. Removed once no list calls it.
285
+ */ export function listFilterPlan(fields, clauses, options = {}) {
283
286
  let served = null;
284
287
  let constraints = null;
285
288
  const window = [];
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/list-filter-constraints.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 documentId,\n endAt,\n orderBy,\n startAt,\n Timestamp,\n where,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport {\n type ListFilterField,\n type ListFilterRequest,\n listFilterDay,\n} from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/**\n * The same filter contract, for a list that queries Firestore from the BROWSER\n * (AGL-2501).\n *\n * The console's paged cards read their collections directly through\n * `usePagedCollection` rather than through a route, so the server translator —\n * which speaks firebase-admin — cannot serve them. This is its twin in the web\n * SDK, reading the same `ListFilterField` declaration so a list cannot offer an\n * operator its query will not answer.\n *\n * The reason it must exist at all is that these cards filtered the rows they\n * had. A paged card holds ten; typing a name that is on page four answers \"no\n * such member\", which reads as the record not existing rather than as the\n * search not reaching it.\n *\n * ⚠️ A predicate here runs under SECURITY RULES, unlike the server twin. A\n * field a rule does not permit reading is not a field this can filter on, and\n * the failure arrives as a permission error rather than as an empty page.\n */\n\n/** Firestore caps `in` at 30 values. */\nconst IN_LIMIT = 30\n\n/** A very high private-use codepoint — see the server twin for why. */\nconst HIGH = '\\uf8ff'\n\nconst csv = (raw: string): string[] =>\n raw\n .split(',')\n .map((entry) => entry.trim())\n .filter(Boolean)\n\nconst key = (value: string): string =>\n value.trim().replace(/\\s+/g, ' ').toLowerCase()\n\n/** Reverse by CODEPOINT — `split('')` tears surrogate pairs in half. */\nconst reversed = (value: string): string => [...key(value)].reverse().join('')\n\nconst toDay = (value: string): { start: Date; end: Date } | null => {\n // The calendar day the clause names, in the reader's zone — see\n // `listFilterDay`; `new Date('YYYY-MM-DD')` is UTC midnight, the evening\n // before anywhere west of UTC.\n const parsed = listFilterDay(value)\n if (Number.isNaN(parsed.getTime())) return null\n const start = new Date(parsed)\n start.setHours(0, 0, 0, 0)\n const end = new Date(start)\n end.setDate(end.getDate() + 1)\n return { start, end }\n}\n\nexport interface ListFilterConstraintOptions {\n /**\n * The list's own sort field, when the caller owns it. Set it and the\n * translator adds predicates WITHOUT an `orderBy` of its own, refusing any\n * that would need a different one — see the server twin's `fixedOrderBy`.\n */\n fixedOrderBy?: string\n /** Sort field for a `contains`, which cannot order by its token array. */\n containsOrderBy?: string\n /**\n * The query already carries an `array-contains-any` over `values` scope\n * tokens (AGL-3321).\n *\n * Firestore takes ONE array clause per query, so a `contains` on a token\n * array cannot stand beside it and is left to the window. `exempt` names\n * the fields whose caller drops the scope clause for them instead. And an\n * `in` multiplies with the scope's values into disjunctions, which\n * Firestore caps at thirty, so an `isAnyOf` longer than the budget left is\n * left to the window too rather than refused at query time.\n */\n arrayScope?: { values: number; exempt?: readonly string[] }\n}\n\n/** The disjunctions Firestore allows one query. */\nconst DISJUNCTION_LIMIT = 30\n\n/**\n * Constraints for one declared filter, or `null` when this list cannot serve\n * the ask.\n *\n * `null` means \"not filtered\", and the caller queries unfiltered. That is\n * deliberate and it is the whole point: an operator the console cannot serve\n * must not read as \"no such record\", which is what an empty page would say.\n */\nexport function listFilterConstraints(\n fields: readonly ListFilterField[],\n input: ListFilterRequest | null,\n options: ListFilterConstraintOptions = {},\n): QueryConstraint[] | null {\n if (!input) return null\n const field = fields.find((entry) => entry.column === input.field)\n if (!field) return null\n // A window-only field is matched over the loaded rows — see the flag.\n if (field.windowOnly) return null\n const raw = (input.value ?? '').trim()\n const op = input.op\n const pinned = options.fixedOrderBy\n const scope = options.arrayScope\n const scoped = Boolean(scope) && !scope?.exempt?.includes(field.column)\n /** How many `in` values fit beside the scope clause's own. */\n const inBudget = scoped\n ? Math.floor(DISJUNCTION_LIMIT / Math.max(1, scope?.values ?? 1))\n : IN_LIMIT\n const inValues = (raw: string): string[] | null => {\n const values = csv(raw)\n return values.length && values.length <= inBudget ? values : null\n }\n const sorted = (by: string | ReturnType<typeof documentId>) =>\n pinned ? [] : [orderBy(by as never)]\n const rangeAllowed = (path: string) => !pinned || pinned === path\n\n if (op === 'isEmpty') {\n // Only where writers store `null`; Firestore cannot find documents that\n // simply LACK a field. See `presence` on the shared declaration.\n if (field.presence !== 'nullable') return null\n return [where(field.path, '==', null), ...sorted(documentId())]\n }\n if (op === 'isNotEmpty') {\n // `!=` also requires the field to EXIST, which is the meaning wanted.\n if (field.presence === 'always') return null\n if (!rangeAllowed(field.path)) return null\n return [where(field.path, '!=', null), ...sorted(field.path)]\n }\n\n if (field.kind === 'boolean') {\n if (raw !== 'true' && raw !== 'false') return null\n return [where(field.path, '==', raw === 'true'), ...sorted(documentId())]\n }\n\n if (!raw) return null\n\n const prefixRange = (path: string, prefix: string) =>\n prefix && rangeAllowed(path)\n ? [orderBy(path), startAt(prefix), endAt(`${prefix}${HIGH}`)]\n : null\n\n if (field.kind === 'text') {\n if (op === 'contains' && field.tokensPath) {\n // One array clause per query, and the scope clause is one.\n if (scoped) return null\n // An id array is matched as typed — see `verbatimTokens`.\n const token = field.verbatimTokens ? raw : key(raw).split(' ')[0]\n if (!token) return null\n const sortBy = field.containsOrderBy ?? options.containsOrderBy ?? field.lowerPath ?? field.path\n return [\n where(field.tokensPath, 'array-contains', token),\n ...(pinned ? [] : [orderBy(sortBy, field.containsOrderDirection ?? 'asc')]),\n ]\n }\n if (op === 'equals' && field.lowerPath) {\n return [where(field.lowerPath, '==', key(raw)), ...sorted(documentId())]\n }\n if (op === 'startsWith' && field.lowerPath) {\n return prefixRange(field.lowerPath, key(raw))\n }\n if (op === 'endsWith' && field.reversedPath) {\n return prefixRange(field.reversedPath, reversed(raw))\n }\n return null\n }\n\n if (field.kind === 'id') {\n if (op === 'equals') {\n return [where(documentId(), '==', raw), ...sorted(documentId())]\n }\n if (op === 'startsWith') {\n if (pinned) return null\n return [orderBy(documentId()), startAt(raw), endAt(`${raw}${HIGH}`)]\n }\n if (op === 'isAnyOf') {\n const values = inValues(raw)\n return values ? [where(documentId(), 'in', values), ...sorted(documentId())] : null\n }\n return null\n }\n\n if (field.keysOf) {\n // One key of a presence map, as the equality on its own path. A key is\n // an identifier the writer chose; anything else could not be a path.\n if (op !== 'equals' || !/^[A-Za-z0-9_]+$/.test(raw)) return null\n return [where(`${field.path}.${raw}`, '==', true), ...sorted(documentId())]\n }\n\n if (field.kind === 'exact') {\n if (op === 'equals') {\n return [where(field.path, '==', raw), ...sorted(documentId())]\n }\n if (op === 'isAnyOf') {\n const values = inValues(raw)\n return values ? [where(field.path, 'in', values), ...sorted(documentId())] : null\n }\n return null\n }\n\n if (field.kind === 'number') {\n const value = Number(raw)\n if (!Number.isFinite(value)) return null\n if (op === '=') {\n return [where(field.path, '==', value), ...sorted(documentId())]\n }\n const comparison: Record<string, '!=' | '>' | '>=' | '<' | '<='> = {\n '!=': '!=',\n '>': '>',\n '>=': '>=',\n '<': '<',\n '<=': '<=',\n }\n const operator = comparison[op]\n if (!operator || !rangeAllowed(field.path)) return null\n return [where(field.path, operator, value), ...sorted(field.path)]\n }\n\n if (field.kind === 'date') {\n const day = toDay(raw)\n if (!day || !rangeAllowed(field.path)) return null\n const stamp = (date: Date) => Timestamp.fromDate(date)\n /*\n * `is` is a DAY, not an instant. A stored timestamp carries a time, so\n * equality against midnight matches nothing — a date column filtered by\n * `is` would answer \"none\" for every row, every time.\n */\n if (op === 'is') {\n // Two bounds as `where`s, not a cursor: a cursor must follow the\n // query's own `orderBy`, and a list that owns its sort (and sorts it\n // descending) would have the day read backwards (AGL-3321).\n return [\n where(field.path, '>=', stamp(day.start)),\n where(field.path, '<', stamp(day.end)),\n ...sorted(field.path),\n ]\n }\n const bound: Record<string, ['>=' | '<', Date]> = {\n after: ['>=', day.end],\n onOrAfter: ['>=', day.start],\n before: ['<', day.start],\n onOrBefore: ['<', day.end],\n }\n const found = bound[op]\n if (!found) return null\n return [\n where(field.path, found[0], stamp(found[1])),\n ...sorted(field.path),\n ]\n }\n\n return null\n}\n\n/** How a list applies several clauses at once — see {@link listFilterPlan}. */\nexport interface ListFilterPlan {\n /** The one clause the query carries, or none. */\n served: ListFilterRequest | null\n /** Its constraints, or `null` when nothing was servable. */\n constraints: QueryConstraint[] | null\n /** Every other clause, matched over the rows the query returns. */\n window: ListFilterRequest[]\n}\n\n/**\n * Several clauses, split into the ONE the query serves and the rest the\n * list matches in memory (AGL-2617).\n *\n * A saved view carries a list of clauses and Firestore composes predicates\n * only through indexes nobody built for every pair of columns — and takes\n * one array clause per query, which the scope predicate already is. So the\n * first clause the translator can serve goes to the query, where it reaches\n * the whole collection, and every other clause narrows the bounded window\n * that query returns. The list says which is which in its caption: a clause\n * that narrows only the window must not read as one that searched\n * everything.\n *\n * First SERVABLE, not first listed: a reader who adds an owner clause (a\n * facet field no query reaches) and then a name prefix should have the\n * name searched, not the owner \"served\" as nothing.\n */\nexport function listFilterPlan(\n fields: readonly ListFilterField[],\n clauses: readonly ListFilterRequest[],\n options: ListFilterConstraintOptions = {},\n): ListFilterPlan {\n let served: ListFilterRequest | null = null\n let constraints: QueryConstraint[] | null = null\n const window: ListFilterRequest[] = []\n for (const clause of clauses) {\n if (!served) {\n const built = listFilterConstraints(fields, clause, options)\n if (built) {\n served = clause\n constraints = built\n continue\n }\n }\n window.push(clause)\n }\n return { served, constraints, window }\n}\n"],"names":["documentId","endAt","orderBy","startAt","Timestamp","where","listFilterDay","IN_LIMIT","HIGH","csv","raw","split","map","entry","trim","filter","Boolean","key","value","replace","toLowerCase","reversed","reverse","join","toDay","parsed","Number","isNaN","getTime","start","Date","setHours","end","setDate","getDate","DISJUNCTION_LIMIT","listFilterConstraints","fields","input","options","scope","field","find","column","windowOnly","op","pinned","fixedOrderBy","arrayScope","scoped","exempt","includes","inBudget","Math","floor","max","values","inValues","length","sorted","by","rangeAllowed","path","presence","kind","prefixRange","prefix","tokensPath","token","verbatimTokens","sortBy","containsOrderBy","lowerPath","containsOrderDirection","reversedPath","keysOf","test","isFinite","comparison","operator","day","stamp","date","fromDate","bound","after","onOrAfter","before","onOrBefore","found","listFilterPlan","clauses","served","constraints","window","clause","built","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/list-filter-constraints.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 documentId,\n endAt,\n orderBy,\n startAt,\n Timestamp,\n where,\n type QueryConstraint,\n} from 'firebase/firestore'\nimport {\n type ListFilterField,\n type ListFilterRequest,\n listFilterDay,\n} from '@aglyn/shared-ui-jsx/const/list-filter'\n\n/**\n * The same filter contract, for a list that queries Firestore from the BROWSER\n * (AGL-2501).\n *\n * The console's paged cards read their collections directly through\n * `usePagedCollection` rather than through a route, so the server translator —\n * which speaks firebase-admin — cannot serve them. This is its twin in the web\n * SDK, reading the same `ListFilterField` declaration so a list cannot offer an\n * operator its query will not answer.\n *\n * The reason it must exist at all is that these cards filtered the rows they\n * had. A paged card holds ten; typing a name that is on page four answers \"no\n * such member\", which reads as the record not existing rather than as the\n * search not reaching it.\n *\n * ⚠️ A predicate here runs under SECURITY RULES, unlike the server twin. A\n * field a rule does not permit reading is not a field this can filter on, and\n * the failure arrives as a permission error rather than as an empty page.\n */\n\n/** Firestore caps `in` at 30 values. */\nconst IN_LIMIT = 30\n\n/** A very high private-use codepoint — see the server twin for why. */\nconst HIGH = '\\uf8ff'\n\nconst csv = (raw: string): string[] =>\n raw\n .split(',')\n .map((entry) => entry.trim())\n .filter(Boolean)\n\nconst key = (value: string): string =>\n value.trim().replace(/\\s+/g, ' ').toLowerCase()\n\n/** Reverse by CODEPOINT — `split('')` tears surrogate pairs in half. */\nconst reversed = (value: string): string => [...key(value)].reverse().join('')\n\nconst toDay = (value: string): { start: Date; end: Date } | null => {\n // The calendar day the clause names, in the reader's zone — see\n // `listFilterDay`; `new Date('YYYY-MM-DD')` is UTC midnight, the evening\n // before anywhere west of UTC.\n const parsed = listFilterDay(value)\n if (Number.isNaN(parsed.getTime())) return null\n const start = new Date(parsed)\n start.setHours(0, 0, 0, 0)\n const end = new Date(start)\n end.setDate(end.getDate() + 1)\n return { start, end }\n}\n\nexport interface ListFilterConstraintOptions {\n /**\n * The list's own sort field, when the caller owns it. Set it and the\n * translator adds predicates WITHOUT an `orderBy` of its own, refusing any\n * that would need a different one — see the server twin's `fixedOrderBy`.\n */\n fixedOrderBy?: string\n /** Sort field for a `contains`, which cannot order by its token array. */\n containsOrderBy?: string\n /**\n * The query already carries an `array-contains-any` over `values` scope\n * tokens (AGL-3321).\n *\n * Firestore takes ONE array clause per query, so a `contains` on a token\n * array cannot stand beside it and is left to the window. `exempt` names\n * the fields whose caller drops the scope clause for them instead. And an\n * `in` multiplies with the scope's values into disjunctions, which\n * Firestore caps at thirty, so an `isAnyOf` longer than the budget left is\n * left to the window too rather than refused at query time.\n */\n arrayScope?: { values: number; exempt?: readonly string[] }\n}\n\n/** The disjunctions Firestore allows one query. */\nconst DISJUNCTION_LIMIT = 30\n\n/**\n * Constraints for one declared filter, or `null` when this list cannot serve\n * the ask.\n *\n * `null` means \"not filtered\", and the caller queries unfiltered. That is\n * deliberate and it is the whole point: an operator the console cannot serve\n * must not read as \"no such record\", which is what an empty page would say.\n */\nexport function listFilterConstraints(\n fields: readonly ListFilterField[],\n input: ListFilterRequest | null,\n options: ListFilterConstraintOptions = {},\n): QueryConstraint[] | null {\n if (!input) return null\n const field = fields.find((entry) => entry.column === input.field)\n if (!field) return null\n // A window-only field is matched over the loaded rows — see the flag.\n if (field.windowOnly) return null\n const raw = (input.value ?? '').trim()\n const op = input.op\n const pinned = options.fixedOrderBy\n const scope = options.arrayScope\n const scoped = Boolean(scope) && !scope?.exempt?.includes(field.column)\n /** How many `in` values fit beside the scope clause's own. */\n const inBudget = scoped\n ? Math.floor(DISJUNCTION_LIMIT / Math.max(1, scope?.values ?? 1))\n : IN_LIMIT\n const inValues = (raw: string): string[] | null => {\n const values = csv(raw)\n return values.length && values.length <= inBudget ? values : null\n }\n const sorted = (by: string | ReturnType<typeof documentId>) =>\n pinned ? [] : [orderBy(by as never)]\n const rangeAllowed = (path: string) => !pinned || pinned === path\n\n if (op === 'isEmpty') {\n // Only where writers store `null`; Firestore cannot find documents that\n // simply LACK a field. See `presence` on the shared declaration.\n if (field.presence !== 'nullable') return null\n return [where(field.path, '==', null), ...sorted(documentId())]\n }\n if (op === 'isNotEmpty') {\n // `!=` also requires the field to EXIST, which is the meaning wanted.\n if (field.presence === 'always') return null\n if (!rangeAllowed(field.path)) return null\n return [where(field.path, '!=', null), ...sorted(field.path)]\n }\n\n if (field.kind === 'boolean') {\n if (raw !== 'true' && raw !== 'false') return null\n return [where(field.path, '==', raw === 'true'), ...sorted(documentId())]\n }\n\n if (!raw) return null\n\n const prefixRange = (path: string, prefix: string) =>\n prefix && rangeAllowed(path)\n ? [orderBy(path), startAt(prefix), endAt(`${prefix}${HIGH}`)]\n : null\n\n if (field.kind === 'text') {\n if (op === 'contains' && field.tokensPath) {\n // One array clause per query, and the scope clause is one.\n if (scoped) return null\n // An id array is matched as typed — see `verbatimTokens`.\n const token = field.verbatimTokens ? raw : key(raw).split(' ')[0]\n if (!token) return null\n const sortBy = field.containsOrderBy ?? options.containsOrderBy ?? field.lowerPath ?? field.path\n return [\n where(field.tokensPath, 'array-contains', token),\n ...(pinned ? [] : [orderBy(sortBy, field.containsOrderDirection ?? 'asc')]),\n ]\n }\n if (op === 'equals' && field.lowerPath) {\n return [where(field.lowerPath, '==', key(raw)), ...sorted(documentId())]\n }\n if (op === 'startsWith' && field.lowerPath) {\n return prefixRange(field.lowerPath, key(raw))\n }\n if (op === 'endsWith' && field.reversedPath) {\n return prefixRange(field.reversedPath, reversed(raw))\n }\n return null\n }\n\n if (field.kind === 'id') {\n if (op === 'equals') {\n return [where(documentId(), '==', raw), ...sorted(documentId())]\n }\n if (op === 'startsWith') {\n if (pinned) return null\n return [orderBy(documentId()), startAt(raw), endAt(`${raw}${HIGH}`)]\n }\n if (op === 'isAnyOf') {\n const values = inValues(raw)\n return values ? [where(documentId(), 'in', values), ...sorted(documentId())] : null\n }\n return null\n }\n\n if (field.keysOf) {\n // One key of a presence map, as the equality on its own path. A key is\n // an identifier the writer chose; anything else could not be a path.\n if (op !== 'equals' || !/^[A-Za-z0-9_]+$/.test(raw)) return null\n return [where(`${field.path}.${raw}`, '==', true), ...sorted(documentId())]\n }\n\n if (field.kind === 'exact') {\n if (op === 'equals') {\n return [where(field.path, '==', raw), ...sorted(documentId())]\n }\n if (op === 'isAnyOf') {\n const values = inValues(raw)\n return values ? [where(field.path, 'in', values), ...sorted(documentId())] : null\n }\n return null\n }\n\n if (field.kind === 'number') {\n const value = Number(raw)\n if (!Number.isFinite(value)) return null\n if (op === '=') {\n return [where(field.path, '==', value), ...sorted(documentId())]\n }\n const comparison: Record<string, '!=' | '>' | '>=' | '<' | '<='> = {\n '!=': '!=',\n '>': '>',\n '>=': '>=',\n '<': '<',\n '<=': '<=',\n }\n const operator = comparison[op]\n if (!operator || !rangeAllowed(field.path)) return null\n return [where(field.path, operator, value), ...sorted(field.path)]\n }\n\n if (field.kind === 'date') {\n const day = toDay(raw)\n if (!day || !rangeAllowed(field.path)) return null\n const stamp = (date: Date) => Timestamp.fromDate(date)\n /*\n * `is` is a DAY, not an instant. A stored timestamp carries a time, so\n * equality against midnight matches nothing — a date column filtered by\n * `is` would answer \"none\" for every row, every time.\n */\n if (op === 'is') {\n // Two bounds as `where`s, not a cursor: a cursor must follow the\n // query's own `orderBy`, and a list that owns its sort (and sorts it\n // descending) would have the day read backwards (AGL-3321).\n return [\n where(field.path, '>=', stamp(day.start)),\n where(field.path, '<', stamp(day.end)),\n ...sorted(field.path),\n ]\n }\n const bound: Record<string, ['>=' | '<', Date]> = {\n after: ['>=', day.end],\n onOrAfter: ['>=', day.start],\n before: ['<', day.start],\n onOrBefore: ['<', day.end],\n }\n const found = bound[op]\n if (!found) return null\n return [\n where(field.path, found[0], stamp(found[1])),\n ...sorted(field.path),\n ]\n }\n\n return null\n}\n\n/** How a list applies several clauses at once — see {@link listFilterPlan}. */\nexport interface ListFilterPlan {\n /** The one clause the query carries, or none. */\n served: ListFilterRequest | null\n /** Its constraints, or `null` when nothing was servable. */\n constraints: QueryConstraint[] | null\n /** Every other clause, matched over the rows the query returns. */\n window: ListFilterRequest[]\n}\n\n/**\n * Several clauses, split into the ONE the query serves and the rest the\n * list matches in memory (AGL-2617).\n *\n * A saved view carries a list of clauses and Firestore composes predicates\n * only through indexes nobody built for every pair of columns — and takes\n * one array clause per query, which the scope predicate already is. So the\n * first clause the translator can serve goes to the query, where it reaches\n * the whole collection, and every other clause narrows the bounded window\n * that query returns. The list says which is which in its caption: a clause\n * that narrows only the window must not read as one that searched\n * everything.\n *\n * First SERVABLE, not first listed: a reader who adds an owner clause (a\n * facet field no query reaches) and then a name prefix should have the\n * name searched, not the owner \"served\" as nothing.\n * @deprecated AGL-3321: matches over rows a list has loaded. Serve every clause and search\n * word on the query instead — `planListQuery` (`@aglyn/shared-ui-jsx/const/list-query-plan`, with `nameSearchNormalizers`)\n * through `useListQuery` or `applyListQuery`. Removed once no list calls it.\n*/\nexport function listFilterPlan(\n fields: readonly ListFilterField[],\n clauses: readonly ListFilterRequest[],\n options: ListFilterConstraintOptions = {},\n): ListFilterPlan {\n let served: ListFilterRequest | null = null\n let constraints: QueryConstraint[] | null = null\n const window: ListFilterRequest[] = []\n for (const clause of clauses) {\n if (!served) {\n const built = listFilterConstraints(fields, clause, options)\n if (built) {\n served = clause\n constraints = built\n continue\n }\n }\n window.push(clause)\n }\n return { served, constraints, window }\n}\n"],"names":["documentId","endAt","orderBy","startAt","Timestamp","where","listFilterDay","IN_LIMIT","HIGH","csv","raw","split","map","entry","trim","filter","Boolean","key","value","replace","toLowerCase","reversed","reverse","join","toDay","parsed","Number","isNaN","getTime","start","Date","setHours","end","setDate","getDate","DISJUNCTION_LIMIT","listFilterConstraints","fields","input","options","scope","field","find","column","windowOnly","op","pinned","fixedOrderBy","arrayScope","scoped","exempt","includes","inBudget","Math","floor","max","values","inValues","length","sorted","by","rangeAllowed","path","presence","kind","prefixRange","prefix","tokensPath","token","verbatimTokens","sortBy","containsOrderBy","lowerPath","containsOrderDirection","reversedPath","keysOf","test","isFinite","comparison","operator","day","stamp","date","fromDate","bound","after","onOrAfter","before","onOrBefore","found","listFilterPlan","clauses","served","constraints","window","clause","built","push"],"mappings":"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"}
@@ -0,0 +1,45 @@
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, type ListQueryPlan, type ListQueryRequest } from '@aglyn/shared-ui-jsx/const/list-query-plan';
18
+ import { type DocumentData, type Query, type QueryConstraint } from 'firebase/firestore';
19
+ import { type DependencyList } from 'react';
20
+ import { type UsePagedCollectionOptions, type UsePagedCollectionResult } from './use-paged-collection';
21
+ /** The plan as web-SDK constraints: every predicate, then its one order. */
22
+ export declare function listQueryConstraints(plan: ListQueryPlan): QueryConstraint[];
23
+ export interface UseListQueryOptions extends UsePagedCollectionOptions {
24
+ /** The collection, or null while its path is not known yet. */
25
+ collection: Query<DocumentData> | null;
26
+ declaration: ListQueryDeclaration;
27
+ request: ListQueryRequest;
28
+ /** Identifies the collection — the primitive ids its path is built from. */
29
+ deps: DependencyList;
30
+ }
31
+ export interface UseListQueryResult<T> extends UsePagedCollectionResult<T> {
32
+ /** What the query holds, what it refused and what it says about it. */
33
+ plan: ListQueryPlan;
34
+ }
35
+ /**
36
+ * A list whose EVERY clause and search word is on its Firestore query
37
+ * (AGL-3321), paged by that query — `usePagedCollection` over the plan's
38
+ * constraints, so each page is a page of matches and none is narrowed in
39
+ * memory afterwards.
40
+ *
41
+ * A new plan is a new query and the pager starts over. The plan comes back
42
+ * beside the rows for the list to show its refusals and notices
43
+ * (`ListQueryNotices`) and to mark which chips reached the query.
44
+ */
45
+ export declare function useListQuery<T = DocumentData>(options: UseListQueryOptions): UseListQueryResult<T>;
@@ -0,0 +1,81 @@
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
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
19
+ import { LIST_QUERY_ID_PATH, planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
20
+ import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
21
+ import { documentId, limit, orderBy, query, Timestamp, where } from "firebase/firestore";
22
+ import { useMemo } from "react";
23
+ import { usePagedCollection } from "./use-paged-collection.js";
24
+ /*
25
+ * The web-SDK twin of the list query plan (AGL-3321): the plan's predicates
26
+ * and its one order as `QueryConstraint`s, for a list that reads Firestore
27
+ * from the browser. The Admin twin is `applyListQuery` in the console.
28
+ *
29
+ * ⚠️ These run under SECURITY RULES. A predicate on a field a rule does not
30
+ * let the reader filter by arrives as a permission error, not an empty page.
31
+ */ const path = (value)=>value === LIST_QUERY_ID_PATH ? documentId() : value;
32
+ const valueOf = (value)=>value instanceof Date ? Timestamp.fromDate(value) : Array.isArray(value) ? [
33
+ ...value
34
+ ] : value;
35
+ /** The plan as web-SDK constraints: every predicate, then its one order. */ export function listQueryConstraints(plan) {
36
+ return [
37
+ ...plan.filters.map((filter)=>where(path(filter.path), filter.op, valueOf(filter.value))),
38
+ orderBy(path(plan.orderBy.path), plan.orderBy.direction)
39
+ ];
40
+ }
41
+ /**
42
+ * A list whose EVERY clause and search word is on its Firestore query
43
+ * (AGL-3321), paged by that query — `usePagedCollection` over the plan's
44
+ * constraints, so each page is a page of matches and none is narrowed in
45
+ * memory afterwards.
46
+ *
47
+ * A new plan is a new query and the pager starts over. The plan comes back
48
+ * beside the rows for the list to show its refusals and notices
49
+ * (`ListQueryNotices`) and to mark which chips reached the query.
50
+ */ export function useListQuery(options) {
51
+ const { collection, declaration, request, deps } = options, pagedOptions = _object_without_properties_loose(options, [
52
+ "collection",
53
+ "declaration",
54
+ "request",
55
+ "deps"
56
+ ]);
57
+ const plan = useMemo(()=>planListQuery(declaration, request, nameSearchNormalizers), // The request is data; its JSON is its identity.
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ [
60
+ declaration,
61
+ JSON.stringify(request)
62
+ ]);
63
+ const planKey = useMemo(()=>JSON.stringify({
64
+ filters: plan.filters,
65
+ orderBy: plan.orderBy
66
+ }), [
67
+ plan
68
+ ]);
69
+ const paged = usePagedCollection((pageLimit)=>{
70
+ if (!collection) return null;
71
+ return query(collection, ...listQueryConstraints(plan), limit(pageLimit));
72
+ }, [
73
+ ...deps,
74
+ planKey
75
+ ], pagedOptions);
76
+ return _extends({}, paged, {
77
+ plan
78
+ });
79
+ }
80
+
81
+ //# sourceMappingURL=use-list-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-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\n'use client'\n\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryDeclaration,\n type ListQueryFilter,\n type ListQueryPlan,\n type ListQueryRequest,\n planListQuery,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { nameSearchNormalizers } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n type DocumentData,\n documentId,\n limit,\n orderBy,\n query,\n type Query,\n type QueryConstraint,\n Timestamp,\n where,\n} from 'firebase/firestore'\nimport { type DependencyList, useMemo } from 'react'\nimport {\n usePagedCollection,\n type UsePagedCollectionOptions,\n type UsePagedCollectionResult,\n} from './use-paged-collection'\n\n/*\n * The web-SDK twin of the list query plan (AGL-3321): the plan's predicates\n * and its one order as `QueryConstraint`s, for a list that reads Firestore\n * from the browser. The Admin twin is `applyListQuery` in the console.\n *\n * ⚠️ These run under SECURITY RULES. A predicate on a field a rule does not\n * let the reader filter by arrives as a permission error, not an empty page.\n */\n\nconst path = (value: string) => (value === LIST_QUERY_ID_PATH ? documentId() : value)\n\nconst valueOf = (value: ListQueryFilter['value']): unknown =>\n value instanceof Date\n ? Timestamp.fromDate(value)\n : Array.isArray(value)\n ? [...value]\n : value\n\n/** The plan as web-SDK constraints: every predicate, then its one order. */\nexport function listQueryConstraints(plan: ListQueryPlan): QueryConstraint[] {\n return [\n ...plan.filters.map((filter) => where(path(filter.path) as never, filter.op, valueOf(filter.value))),\n orderBy(path(plan.orderBy.path) as never, plan.orderBy.direction),\n ]\n}\n\nexport interface UseListQueryOptions extends UsePagedCollectionOptions {\n /** The collection, or null while its path is not known yet. */\n collection: Query<DocumentData> | null\n declaration: ListQueryDeclaration\n request: ListQueryRequest\n /** Identifies the collection — the primitive ids its path is built from. */\n deps: DependencyList\n}\n\nexport interface UseListQueryResult<T> extends UsePagedCollectionResult<T> {\n /** What the query holds, what it refused and what it says about it. */\n plan: ListQueryPlan\n}\n\n/**\n * A list whose EVERY clause and search word is on its Firestore query\n * (AGL-3321), paged by that query — `usePagedCollection` over the plan's\n * constraints, so each page is a page of matches and none is narrowed in\n * memory afterwards.\n *\n * A new plan is a new query and the pager starts over. The plan comes back\n * beside the rows for the list to show its refusals and notices\n * (`ListQueryNotices`) and to mark which chips reached the query.\n */\nexport function useListQuery<T = DocumentData>(\n options: UseListQueryOptions,\n): UseListQueryResult<T> {\n const { collection, declaration, request, deps, ...pagedOptions } = options\n const plan = useMemo(\n () => planListQuery(declaration, request, nameSearchNormalizers),\n // The request is data; its JSON is its identity.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [declaration, JSON.stringify(request)],\n )\n const planKey = useMemo(\n () => JSON.stringify({ filters: plan.filters, orderBy: plan.orderBy }),\n [plan],\n )\n const paged = usePagedCollection<T>(\n (pageLimit) => {\n if (!collection) return null\n return query(collection, ...listQueryConstraints(plan), limit(pageLimit))\n },\n [...deps, planKey],\n pagedOptions,\n )\n return { ...paged, plan }\n}\n"],"names":["LIST_QUERY_ID_PATH","planListQuery","nameSearchNormalizers","documentId","limit","orderBy","query","Timestamp","where","useMemo","usePagedCollection","path","value","valueOf","Date","fromDate","Array","isArray","listQueryConstraints","plan","filters","map","filter","op","direction","useListQuery","options","collection","declaration","request","deps","pagedOptions","JSON","stringify","planKey","paged","pageLimit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;AAEA,SACEA,kBAAkB,EAKlBC,aAAa,QACR,6CAA4C;AACnD,SAASC,qBAAqB,QAAQ,qCAAoC;AAC1E,SAEEC,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,KAAK,EAGLC,SAAS,EACTC,KAAK,QACA,qBAAoB;AAC3B,SAA8BC,OAAO,QAAQ,QAAO;AACpD,SACEC,kBAAkB,QAGb,4BAAwB;AAE/B;;;;;;;CAOC,GAED,MAAMC,OAAO,CAACC,QAAmBA,UAAUZ,qBAAqBG,eAAeS;AAE/E,MAAMC,UAAU,CAACD,QACfA,iBAAiBE,OACbP,UAAUQ,QAAQ,CAACH,SACnBI,MAAMC,OAAO,CAACL,SACZ;WAAIA;KAAM,GACVA;AAER,0EAA0E,GAC1E,OAAO,SAASM,qBAAqBC,IAAmB;IACtD,OAAO;WACFA,KAAKC,OAAO,CAACC,GAAG,CAAC,CAACC,SAAWd,MAAMG,KAAKW,OAAOX,IAAI,GAAYW,OAAOC,EAAE,EAAEV,QAAQS,OAAOV,KAAK;QACjGP,QAAQM,KAAKQ,KAAKd,OAAO,CAACM,IAAI,GAAYQ,KAAKd,OAAO,CAACmB,SAAS;KACjE;AACH;AAgBA;;;;;;;;;CASC,GACD,OAAO,SAASC,aACdC,OAA4B;IAE5B,MAAM,EAAEC,UAAU,EAAEC,WAAW,EAAEC,OAAO,EAAEC,IAAI,EAAmB,GAAGJ,SAAjBK,gDAAiBL;;;;;;IACpE,MAAMP,OAAOV,QACX,IAAMR,cAAc2B,aAAaC,SAAS3B,wBAC1C,iDAAiD;IACjD,uDAAuD;IACvD;QAAC0B;QAAaI,KAAKC,SAAS,CAACJ;KAAS;IAExC,MAAMK,UAAUzB,QACd,IAAMuB,KAAKC,SAAS,CAAC;YAAEb,SAASD,KAAKC,OAAO;YAAEf,SAASc,KAAKd,OAAO;QAAC,IACpE;QAACc;KAAK;IAER,MAAMgB,QAAQzB,mBACZ,CAAC0B;QACC,IAAI,CAACT,YAAY,OAAO;QACxB,OAAOrB,MAAMqB,eAAeT,qBAAqBC,OAAOf,MAAMgC;IAChE,GACA;WAAIN;QAAMI;KAAQ,EAClBH;IAEF,OAAO,aAAKI;QAAOhB;;AACrB"}