@aglyn/tenant-feature-instance 1.0.0-beta.198 → 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 +6 -6
- package/src/lib/hooks/list-filter-constraints.d.ts +4 -1
- package/src/lib/hooks/list-filter-constraints.js +4 -1
- package/src/lib/hooks/list-filter-constraints.js.map +1 -1
- package/src/lib/hooks/use-host-activity-logger.js +9 -1
- package/src/lib/hooks/use-host-activity-logger.js.map +1 -1
- package/src/lib/hooks/use-list-query.d.ts +45 -0
- package/src/lib/hooks/use-list-query.js +81 -0
- package/src/lib/hooks/use-list-query.js.map +1 -0
- package/src/lib/testing/list-query-double.d.ts +44 -0
- package/src/lib/testing/list-query-double.js +140 -0
- package/src/lib/testing/list-query-double.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-feature-instance",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.200",
|
|
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.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.200",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.200",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.200",
|
|
31
|
+
"@aglyn/shared-util-http": "1.0.0-beta.200",
|
|
32
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.200",
|
|
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
|
-
|
|
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":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,UAAU,EACVC,KAAK,EACLC,OAAO,EACPC,OAAO,EACPC,SAAS,EACTC,KAAK,QAEA,qBAAoB;AAC3B,SAGEC,aAAa,QACR,yCAAwC;AAE/C;;;;;;;;;;;;;;;;;;CAkBC,GAED,sCAAsC,GACtC,MAAMC,WAAW;AAEjB,qEAAqE,GACrE,MAAMC,OAAO;AAEb,MAAMC,MAAM,CAACC,MACXA,IACGC,KAAK,CAAC,KACNC,GAAG,CAAC,CAACC,QAAUA,MAAMC,IAAI,IACzBC,MAAM,CAACC;AAEZ,MAAMC,MAAM,CAACC,QACXA,MAAMJ,IAAI,GAAGK,OAAO,CAAC,QAAQ,KAAKC,WAAW;AAE/C,sEAAsE,GACtE,MAAMC,WAAW,CAACH,QAA0B;WAAID,IAAIC;KAAO,CAACI,OAAO,GAAGC,IAAI,CAAC;AAE3E,MAAMC,QAAQ,CAACN;IACb,gEAAgE;IAChE,yEAAyE;IACzE,+BAA+B;IAC/B,MAAMO,SAASnB,cAAcY;IAC7B,IAAIQ,OAAOC,KAAK,CAACF,OAAOG,OAAO,KAAK,OAAO;IAC3C,MAAMC,QAAQ,IAAIC,KAAKL;IACvBI,MAAME,QAAQ,CAAC,GAAG,GAAG,GAAG;IACxB,MAAMC,MAAM,IAAIF,KAAKD;IACrBG,IAAIC,OAAO,CAACD,IAAIE,OAAO,KAAK;IAC5B,OAAO;QAAEL;QAAOG;IAAI;AACtB;AAyBA,iDAAiD,GACjD,MAAMG,oBAAoB;AAE1B;;;;;;;CAOC,GACD,OAAO,SAASC,sBACdC,MAAkC,EAClCC,KAA+B,EAC/BC,UAAuC,CAAC,CAAC;QAO5BD;QAIqBE;IATlC,IAAI,CAACF,OAAO,OAAO;IACnB,MAAMG,QAAQJ,OAAOK,IAAI,CAAC,CAAC7B,QAAUA,MAAM8B,MAAM,KAAKL,MAAMG,KAAK;IACjE,IAAI,CAACA,OAAO,OAAO;IACnB,sEAAsE;IACtE,IAAIA,MAAMG,UAAU,EAAE,OAAO;IAC7B,MAAMlC,MAAM,EAAC4B,eAAAA,MAAMpB,KAAK,YAAXoB,eAAe,IAAIxB,IAAI;IACpC,MAAM+B,KAAKP,MAAMO,EAAE;IACnB,MAAMC,SAASP,QAAQQ,YAAY;IACnC,MAAMP,QAAQD,QAAQS,UAAU;IAChC,MAAMC,SAASjC,QAAQwB,UAAU,EAACA,0BAAAA,gBAAAA,MAAOU,MAAM,qBAAbV,cAAeW,QAAQ,CAACV,MAAME,MAAM;IACtE,4DAA4D,GAC5D,MAAMS,WAAWH,SACbI,KAAKC,KAAK,CAACnB,oBAAoBkB,KAAKE,GAAG,CAAC,WAAGf,yBAAAA,MAAOgB,MAAM,mBAAI,MAC5DjD;IACJ,MAAMkD,WAAW,CAAC/C;QAChB,MAAM8C,SAAS/C,IAAIC;QACnB,OAAO8C,OAAOE,MAAM,IAAIF,OAAOE,MAAM,IAAIN,WAAWI,SAAS;IAC/D;IACA,MAAMG,SAAS,CAACC,KACdd,SAAS,EAAE,GAAG;YAAC5C,QAAQ0D;SAAa;IACtC,MAAMC,eAAe,CAACC,OAAiB,CAAChB,UAAUA,WAAWgB;IAE7D,IAAIjB,OAAO,WAAW;QACpB,wEAAwE;QACxE,iEAAiE;QACjE,IAAIJ,MAAMsB,QAAQ,KAAK,YAAY,OAAO;QAC1C,OAAO;YAAC1D,MAAMoC,MAAMqB,IAAI,EAAE,MAAM;eAAUH,OAAO3D;SAAc;IACjE;IACA,IAAI6C,OAAO,cAAc;QACvB,sEAAsE;QACtE,IAAIJ,MAAMsB,QAAQ,KAAK,UAAU,OAAO;QACxC,IAAI,CAACF,aAAapB,MAAMqB,IAAI,GAAG,OAAO;QACtC,OAAO;YAACzD,MAAMoC,MAAMqB,IAAI,EAAE,MAAM;eAAUH,OAAOlB,MAAMqB,IAAI;SAAE;IAC/D;IAEA,IAAIrB,MAAMuB,IAAI,KAAK,WAAW;QAC5B,IAAItD,QAAQ,UAAUA,QAAQ,SAAS,OAAO;QAC9C,OAAO;YAACL,MAAMoC,MAAMqB,IAAI,EAAE,MAAMpD,QAAQ;eAAYiD,OAAO3D;SAAc;IAC3E;IAEA,IAAI,CAACU,KAAK,OAAO;IAEjB,MAAMuD,cAAc,CAACH,MAAcI,SACjCA,UAAUL,aAAaC,QACnB;YAAC5D,QAAQ4D;YAAO3D,QAAQ+D;YAASjE,MAAM,GAAGiE,SAAS1D,MAAM;SAAE,GAC3D;IAEN,IAAIiC,MAAMuB,IAAI,KAAK,QAAQ;QACzB,IAAInB,OAAO,cAAcJ,MAAM0B,UAAU,EAAE;gBAM1B1B,OAAAA,OAAAA,wBAGsBA;YARrC,2DAA2D;YAC3D,IAAIQ,QAAQ,OAAO;YACnB,0DAA0D;YAC1D,MAAMmB,QAAQ3B,MAAM4B,cAAc,GAAG3D,MAAMO,IAAIP,KAAKC,KAAK,CAAC,IAAI,CAAC,EAAE;YACjE,IAAI,CAACyD,OAAO,OAAO;YACnB,MAAME,UAAS7B,SAAAA,SAAAA,yBAAAA,MAAM8B,eAAe,YAArB9B,yBAAyBF,QAAQgC,eAAe,YAAhD9B,QAAoDA,MAAM+B,SAAS,YAAnE/B,QAAuEA,MAAMqB,IAAI;YAChG,OAAO;gBACLzD,MAAMoC,MAAM0B,UAAU,EAAE,kBAAkBC;mBACtCtB,SAAS,EAAE,GAAG;oBAAC5C,QAAQoE,SAAQ7B,gCAAAA,MAAMgC,sBAAsB,YAA5BhC,gCAAgC;iBAAO;aAC3E;QACH;QACA,IAAII,OAAO,YAAYJ,MAAM+B,SAAS,EAAE;YACtC,OAAO;gBAACnE,MAAMoC,MAAM+B,SAAS,EAAE,MAAMvD,IAAIP;mBAAUiD,OAAO3D;aAAc;QAC1E;QACA,IAAI6C,OAAO,gBAAgBJ,MAAM+B,SAAS,EAAE;YAC1C,OAAOP,YAAYxB,MAAM+B,SAAS,EAAEvD,IAAIP;QAC1C;QACA,IAAImC,OAAO,cAAcJ,MAAMiC,YAAY,EAAE;YAC3C,OAAOT,YAAYxB,MAAMiC,YAAY,EAAErD,SAASX;QAClD;QACA,OAAO;IACT;IAEA,IAAI+B,MAAMuB,IAAI,KAAK,MAAM;QACvB,IAAInB,OAAO,UAAU;YACnB,OAAO;gBAACxC,MAAML,cAAc,MAAMU;mBAASiD,OAAO3D;aAAc;QAClE;QACA,IAAI6C,OAAO,cAAc;YACvB,IAAIC,QAAQ,OAAO;YACnB,OAAO;gBAAC5C,QAAQF;gBAAeG,QAAQO;gBAAMT,MAAM,GAAGS,MAAMF,MAAM;aAAE;QACtE;QACA,IAAIqC,OAAO,WAAW;YACpB,MAAMW,SAASC,SAAS/C;YACxB,OAAO8C,SAAS;gBAACnD,MAAML,cAAc,MAAMwD;mBAAYG,OAAO3D;aAAc,GAAG;QACjF;QACA,OAAO;IACT;IAEA,IAAIyC,MAAMkC,MAAM,EAAE;QAChB,uEAAuE;QACvE,qEAAqE;QACrE,IAAI9B,OAAO,YAAY,CAAC,kBAAkB+B,IAAI,CAAClE,MAAM,OAAO;QAC5D,OAAO;YAACL,MAAM,GAAGoC,MAAMqB,IAAI,CAAC,CAAC,EAAEpD,KAAK,EAAE,MAAM;eAAUiD,OAAO3D;SAAc;IAC7E;IAEA,IAAIyC,MAAMuB,IAAI,KAAK,SAAS;QAC1B,IAAInB,OAAO,UAAU;YACnB,OAAO;gBAACxC,MAAMoC,MAAMqB,IAAI,EAAE,MAAMpD;mBAASiD,OAAO3D;aAAc;QAChE;QACA,IAAI6C,OAAO,WAAW;YACpB,MAAMW,SAASC,SAAS/C;YACxB,OAAO8C,SAAS;gBAACnD,MAAMoC,MAAMqB,IAAI,EAAE,MAAMN;mBAAYG,OAAO3D;aAAc,GAAG;QAC/E;QACA,OAAO;IACT;IAEA,IAAIyC,MAAMuB,IAAI,KAAK,UAAU;QAC3B,MAAM9C,QAAQQ,OAAOhB;QACrB,IAAI,CAACgB,OAAOmD,QAAQ,CAAC3D,QAAQ,OAAO;QACpC,IAAI2B,OAAO,KAAK;YACd,OAAO;gBAACxC,MAAMoC,MAAMqB,IAAI,EAAE,MAAM5C;mBAAWyC,OAAO3D;aAAc;QAClE;QACA,MAAM8E,aAA6D;YACjE,MAAM;YACN,KAAK;YACL,MAAM;YACN,KAAK;YACL,MAAM;QACR;QACA,MAAMC,WAAWD,UAAU,CAACjC,GAAG;QAC/B,IAAI,CAACkC,YAAY,CAAClB,aAAapB,MAAMqB,IAAI,GAAG,OAAO;QACnD,OAAO;YAACzD,MAAMoC,MAAMqB,IAAI,EAAEiB,UAAU7D;eAAWyC,OAAOlB,MAAMqB,IAAI;SAAE;IACpE;IAEA,IAAIrB,MAAMuB,IAAI,KAAK,QAAQ;QACzB,MAAMgB,MAAMxD,MAAMd;QAClB,IAAI,CAACsE,OAAO,CAACnB,aAAapB,MAAMqB,IAAI,GAAG,OAAO;QAC9C,MAAMmB,QAAQ,CAACC,OAAe9E,UAAU+E,QAAQ,CAACD;QACjD;;;;KAIC,GACD,IAAIrC,OAAO,MAAM;YACf,iEAAiE;YACjE,qEAAqE;YACrE,4DAA4D;YAC5D,OAAO;gBACLxC,MAAMoC,MAAMqB,IAAI,EAAE,MAAMmB,MAAMD,IAAInD,KAAK;gBACvCxB,MAAMoC,MAAMqB,IAAI,EAAE,KAAKmB,MAAMD,IAAIhD,GAAG;mBACjC2B,OAAOlB,MAAMqB,IAAI;aACrB;QACH;QACA,MAAMsB,QAA4C;YAChDC,OAAO;gBAAC;gBAAML,IAAIhD,GAAG;aAAC;YACtBsD,WAAW;gBAAC;gBAAMN,IAAInD,KAAK;aAAC;YAC5B0D,QAAQ;gBAAC;gBAAKP,IAAInD,KAAK;aAAC;YACxB2D,YAAY;gBAAC;gBAAKR,IAAIhD,GAAG;aAAC;QAC5B;QACA,MAAMyD,QAAQL,KAAK,CAACvC,GAAG;QACvB,IAAI,CAAC4C,OAAO,OAAO;QACnB,OAAO;YACLpF,MAAMoC,MAAMqB,IAAI,EAAE2B,KAAK,CAAC,EAAE,EAAER,MAAMQ,KAAK,CAAC,EAAE;eACvC9B,OAAOlB,MAAMqB,IAAI;SACrB;IACH;IAEA,OAAO;AACT;AAYA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAAS4B,eACdrD,MAAkC,EAClCsD,OAAqC,EACrCpD,UAAuC,CAAC,CAAC;IAEzC,IAAIqD,SAAmC;IACvC,IAAIC,cAAwC;IAC5C,MAAMC,SAA8B,EAAE;IACtC,KAAK,MAAMC,UAAUJ,QAAS;QAC5B,IAAI,CAACC,QAAQ;YACX,MAAMI,QAAQ5D,sBAAsBC,QAAQ0D,QAAQxD;YACpD,IAAIyD,OAAO;gBACTJ,SAASG;gBACTF,cAAcG;gBACd;YACF;QACF;QACAF,OAAOG,IAAI,CAACF;IACd;IACA,OAAO;QAAEH;QAAQC;QAAaC;IAAO;AACvC"}
|
|
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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"}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
17
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
18
|
+
import { activitySearchTokens } from "@aglyn/aglyn/app-utils/activity-search";
|
|
18
19
|
import { reportHandledError } from "@aglyn/aglyn/app-utils/error-beacon";
|
|
19
20
|
import { Timestamp } from "@aglyn/shared-util-timestamp";
|
|
20
21
|
import { addDoc, collection } from "firebase/firestore";
|
|
@@ -70,9 +71,10 @@ import { useFirestore, useUser } from "./firebase/firebase-services.js";
|
|
|
70
71
|
return useCallback((action, target)=>{
|
|
71
72
|
var _user_email;
|
|
72
73
|
if (!hostId || !user) return;
|
|
74
|
+
const actorEmail = (_user_email = user.email) != null ? _user_email : null;
|
|
73
75
|
void addDoc(collection(firestore, 'hosts', hostId, 'activity'), {
|
|
74
76
|
actorId: user.uid,
|
|
75
|
-
actorEmail
|
|
77
|
+
actorEmail,
|
|
76
78
|
action,
|
|
77
79
|
target: _extends({
|
|
78
80
|
type: target.type
|
|
@@ -83,6 +85,12 @@ import { useFirestore, useUser } from "./firebase/firebase-services.js";
|
|
|
83
85
|
} : {}, target.versionId ? {
|
|
84
86
|
versionId: target.versionId
|
|
85
87
|
} : {}),
|
|
88
|
+
// What the log's search box finds the entry by (AGL-3321). The host
|
|
89
|
+
// catch-all rule validates no keys, so the field needs no rule.
|
|
90
|
+
searchTokens: activitySearchTokens({
|
|
91
|
+
actorEmail,
|
|
92
|
+
target
|
|
93
|
+
}),
|
|
86
94
|
createdAt: Timestamp.now()
|
|
87
95
|
}).catch(reportDroppedEntry);
|
|
88
96
|
}, [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-activity-logger.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'use client'\n\nimport { reportHandledError } from '@aglyn/aglyn/app-utils/error-beacon'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { addDoc, collection } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport { useFirestore, useUser } from './firebase/firebase-services'\n\n/** What an activity entry points at; `id` lets detail pages filter. */\nexport interface HostActivityTarget {\n type:\n | 'host'\n | 'screen'\n | 'layout'\n | 'theme'\n | 'media'\n | 'content'\n | 'variable'\n | 'function'\n | 'workflow'\n | 'member'\n // Standalone editors (AGL-680/681) — both are edited on their own now,\n // so their saves belong in the same log as screens and layouts.\n | 'component'\n | 'template'\n // A person on the CRM record page (AGL-2596): added by hand, edited, or\n // removed from the site. The record is org-shared, but the act happened\n // in THIS site's console and belongs in this site's feed.\n | 'contact'\n // The rest of the CRM (AGL-2622): a company or a deal created or deleted\n // from this site's console — one at a time, or one line per record from\n // the bulk bars (AGL-2621) — and a lead converted into the three. Org\n // data all of them, filed under the site whose console did the work for\n // the reason a contact is.\n | 'company'\n | 'deal'\n | 'lead'\n id?: string\n name?: string\n /**\n * Version-addressed targets (screens) record this so the feed can deep-link\n * straight to the exact version's detail view (AGL-812). Written only when\n * truthy, like `id`/`name`.\n */\n versionId?: string\n}\n\n/** One console line per session, however many appends fail. */\nlet announced = false\n\n/**\n * What a dropped audit append does instead of nothing.\n *\n * The append stays fire-and-forget: an audit miss must never break the edit\n * that triggered it, and a snackbar about the LOG would be a worse answer to\n * \"your page saved\" than silence. But a rejection with nowhere to go is a\n * feature that can break for months without a single signal, so the failure\n * leaves by two doors that cost the person nothing.\n *\n * The beacon is the durable one — it reaches Cloud Error Reporting, dedupes a\n * failure that repeats on every edit into one report, and caps itself, so a\n * permission denial shows up as a graph instead of a support ticket. The\n * console line is for whoever has devtools open, and fires once: the second\n * failure carries no information the first did not, and a message per edit\n * buries the diagnostics that do.\n */\nfunction reportDroppedEntry(error: unknown): void {\n reportHandledError(error, { kind: 'host-activity-write' })\n if (announced) return\n announced = true\n console.error(\n 'Activity logging failed; entries from this session are being dropped.',\n error,\n )\n}\n\n/**\n * User activity log (AGL-118): fire-and-forget appends to\n * `hosts/{hostId}/activity` from console mutation points. Never throws —\n * an audit miss must not break the edit that triggered it. Covered by the\n * host-admin wildcard rule, so no rules change is needed.\n *\n * `activity` is deliberately absent from the host catch-all's create\n * exclusion list, so a member holding a content role writes here directly.\n * `cloud/rules-tests/firestore-rules.test.mjs` pins that on both sides —\n * the roles that may append and the roles that may not.\n *\n * An append needs an ACTOR, and the rule that admits it needs one too:\n * `canWriteHostContent` resolves the writer's host role out of\n * `memberRoles[request.auth.uid]`, so a request carrying no auth matches no\n * role and is refused. `useUser()` reports `undefined` until\n * `onIdTokenChanged` first fires and `null` once it reports a signed-out\n * tab, and in both states the entry has nobody to attribute — so the append\n * is skipped rather than sent to be denied. An unattributed audit line is\n * not a smaller version of an audit line; it records that something happened\n * and refuses to say who did it, which is the one field the log exists for.\n */\nexport function useHostActivityLogger(hostId: string | undefined) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n return useCallback(\n (action: string, target: HostActivityTarget) => {\n if (!hostId || !user) return\n void addDoc(collection(firestore, 'hosts', hostId, 'activity'), {\n actorId: user.uid,\n actorEmail
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-activity-logger.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'use client'\n\nimport { activitySearchTokens } from '@aglyn/aglyn/app-utils/activity-search'\nimport { reportHandledError } from '@aglyn/aglyn/app-utils/error-beacon'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport { addDoc, collection } from 'firebase/firestore'\nimport { useCallback } from 'react'\nimport { useFirestore, useUser } from './firebase/firebase-services'\n\n/** What an activity entry points at; `id` lets detail pages filter. */\nexport interface HostActivityTarget {\n type:\n | 'host'\n | 'screen'\n | 'layout'\n | 'theme'\n | 'media'\n | 'content'\n | 'variable'\n | 'function'\n | 'workflow'\n | 'member'\n // Standalone editors (AGL-680/681) — both are edited on their own now,\n // so their saves belong in the same log as screens and layouts.\n | 'component'\n | 'template'\n // A person on the CRM record page (AGL-2596): added by hand, edited, or\n // removed from the site. The record is org-shared, but the act happened\n // in THIS site's console and belongs in this site's feed.\n | 'contact'\n // The rest of the CRM (AGL-2622): a company or a deal created or deleted\n // from this site's console — one at a time, or one line per record from\n // the bulk bars (AGL-2621) — and a lead converted into the three. Org\n // data all of them, filed under the site whose console did the work for\n // the reason a contact is.\n | 'company'\n | 'deal'\n | 'lead'\n id?: string\n name?: string\n /**\n * Version-addressed targets (screens) record this so the feed can deep-link\n * straight to the exact version's detail view (AGL-812). Written only when\n * truthy, like `id`/`name`.\n */\n versionId?: string\n}\n\n/** One console line per session, however many appends fail. */\nlet announced = false\n\n/**\n * What a dropped audit append does instead of nothing.\n *\n * The append stays fire-and-forget: an audit miss must never break the edit\n * that triggered it, and a snackbar about the LOG would be a worse answer to\n * \"your page saved\" than silence. But a rejection with nowhere to go is a\n * feature that can break for months without a single signal, so the failure\n * leaves by two doors that cost the person nothing.\n *\n * The beacon is the durable one — it reaches Cloud Error Reporting, dedupes a\n * failure that repeats on every edit into one report, and caps itself, so a\n * permission denial shows up as a graph instead of a support ticket. The\n * console line is for whoever has devtools open, and fires once: the second\n * failure carries no information the first did not, and a message per edit\n * buries the diagnostics that do.\n */\nfunction reportDroppedEntry(error: unknown): void {\n reportHandledError(error, { kind: 'host-activity-write' })\n if (announced) return\n announced = true\n console.error(\n 'Activity logging failed; entries from this session are being dropped.',\n error,\n )\n}\n\n/**\n * User activity log (AGL-118): fire-and-forget appends to\n * `hosts/{hostId}/activity` from console mutation points. Never throws —\n * an audit miss must not break the edit that triggered it. Covered by the\n * host-admin wildcard rule, so no rules change is needed.\n *\n * `activity` is deliberately absent from the host catch-all's create\n * exclusion list, so a member holding a content role writes here directly.\n * `cloud/rules-tests/firestore-rules.test.mjs` pins that on both sides —\n * the roles that may append and the roles that may not.\n *\n * An append needs an ACTOR, and the rule that admits it needs one too:\n * `canWriteHostContent` resolves the writer's host role out of\n * `memberRoles[request.auth.uid]`, so a request carrying no auth matches no\n * role and is refused. `useUser()` reports `undefined` until\n * `onIdTokenChanged` first fires and `null` once it reports a signed-out\n * tab, and in both states the entry has nobody to attribute — so the append\n * is skipped rather than sent to be denied. An unattributed audit line is\n * not a smaller version of an audit line; it records that something happened\n * and refuses to say who did it, which is the one field the log exists for.\n */\nexport function useHostActivityLogger(hostId: string | undefined) {\n const firestore = useFirestore()\n const { data: user } = useUser()\n return useCallback(\n (action: string, target: HostActivityTarget) => {\n if (!hostId || !user) return\n const actorEmail = user.email ?? null\n void addDoc(collection(firestore, 'hosts', hostId, 'activity'), {\n actorId: user.uid,\n actorEmail,\n action,\n target: {\n type: target.type,\n ...(target.id ? { id: target.id } : {}),\n ...(target.name ? { name: target.name } : {}),\n ...(target.versionId ? { versionId: target.versionId } : {}),\n },\n // What the log's search box finds the entry by (AGL-3321). The host\n // catch-all rule validates no keys, so the field needs no rule.\n searchTokens: activitySearchTokens({ actorEmail, target }),\n createdAt: Timestamp.now(),\n }).catch(reportDroppedEntry)\n },\n [firestore, hostId, user],\n )\n}\n\nexport default useHostActivityLogger\n"],"names":["activitySearchTokens","reportHandledError","Timestamp","addDoc","collection","useCallback","useFirestore","useUser","announced","reportDroppedEntry","error","kind","console","useHostActivityLogger","hostId","firestore","data","user","action","target","actorEmail","email","actorId","uid","type","id","name","versionId","searchTokens","createdAt","now","catch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,oBAAoB,QAAQ,yCAAwC;AAC7E,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,SAAS,QAAQ,+BAA8B;AACxD,SAASC,MAAM,EAAEC,UAAU,QAAQ,qBAAoB;AACvD,SAASC,WAAW,QAAQ,QAAO;AACnC,SAASC,YAAY,EAAEC,OAAO,QAAQ,kCAA8B;AAyCpE,6DAA6D,GAC7D,IAAIC,YAAY;AAEhB;;;;;;;;;;;;;;;CAeC,GACD,SAASC,mBAAmBC,KAAc;IACxCT,mBAAmBS,OAAO;QAAEC,MAAM;IAAsB;IACxD,IAAIH,WAAW;IACfA,YAAY;IACZI,QAAQF,KAAK,CACX,yEACAA;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASG,sBAAsBC,MAA0B;IAC9D,MAAMC,YAAYT;IAClB,MAAM,EAAEU,MAAMC,IAAI,EAAE,GAAGV;IACvB,OAAOF,YACL,CAACa,QAAgBC;YAEIF;QADnB,IAAI,CAACH,UAAU,CAACG,MAAM;QACtB,MAAMG,cAAaH,cAAAA,KAAKI,KAAK,YAAVJ,cAAc;QACjC,KAAKd,OAAOC,WAAWW,WAAW,SAASD,QAAQ,aAAa;YAC9DQ,SAASL,KAAKM,GAAG;YACjBH;YACAF;YACAC,QAAQ;gBACNK,MAAML,OAAOK,IAAI;eACbL,OAAOM,EAAE,GAAG;gBAAEA,IAAIN,OAAOM,EAAE;YAAC,IAAI,CAAC,GACjCN,OAAOO,IAAI,GAAG;gBAAEA,MAAMP,OAAOO,IAAI;YAAC,IAAI,CAAC,GACvCP,OAAOQ,SAAS,GAAG;gBAAEA,WAAWR,OAAOQ,SAAS;YAAC,IAAI,CAAC;YAE5D,oEAAoE;YACpE,gEAAgE;YAChEC,cAAc5B,qBAAqB;gBAAEoB;gBAAYD;YAAO;YACxDU,WAAW3B,UAAU4B,GAAG;QAC1B,GAAGC,KAAK,CAACtB;IACX,GACA;QAACM;QAAWD;QAAQG;KAAK;AAE7B;AAEA,eAAeJ,sBAAqB"}
|
|
@@ -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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
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 ListQueryFilter, type ListQueryPlan } from '@aglyn/shared-ui-jsx/const/list-query-plan';
|
|
18
|
+
import type { UseListQueryOptions, UseListQueryResult } from '../hooks/use-list-query';
|
|
19
|
+
type Row = Record<string, unknown> & {
|
|
20
|
+
$id?: string;
|
|
21
|
+
};
|
|
22
|
+
/** Whether a row answers one predicate, as Firestore reads it. */
|
|
23
|
+
export declare function rowAnswers(row: Row, filter: ListQueryFilter): boolean;
|
|
24
|
+
/** The rows a plan's query returns, in its order — Firestore's answer, restated. */
|
|
25
|
+
export declare function answerListQuery<T extends Row>(rows: readonly T[], plan: ListQueryPlan): T[];
|
|
26
|
+
/** The plan the double answered last, for a spec to assert what was asked. */
|
|
27
|
+
export declare const lastListQueryPlan: () => ListQueryPlan | null;
|
|
28
|
+
/**
|
|
29
|
+
* `useListQuery`, answered from `rows()` instead of Firestore — same
|
|
30
|
+
* signature, same paging surface as `usePagedCollection`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function useListQueryDouble<T = Row>(rows: () => readonly Row[], options: UseListQueryOptions): UseListQueryResult<T>;
|
|
33
|
+
/**
|
|
34
|
+
* A module to hand `jest.mock('…/hooks/use-list-query', …)`: the double in
|
|
35
|
+
* place of the hook, the real `listQueryConstraints` beside it.
|
|
36
|
+
*
|
|
37
|
+
* `actual` is the real module, which the spec's factory passes in as
|
|
38
|
+
* `jest.requireActual(…)`. Jest injects `jest` into each spec module rather
|
|
39
|
+
* than setting it on `globalThis`, so this file cannot reach it itself.
|
|
40
|
+
*/
|
|
41
|
+
export declare function listQueryModule(rows: () => readonly Row[], actual: object): {
|
|
42
|
+
useListQuery: (options: UseListQueryOptions) => UseListQueryResult<Row>;
|
|
43
|
+
};
|
|
44
|
+
export {};
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
*/ /**
|
|
18
|
+
* A TEST DOUBLE for `useListQuery` (AGL-3321) — for specs only.
|
|
19
|
+
*
|
|
20
|
+
* It runs the REAL `planListQuery` over the request, then answers the plan
|
|
21
|
+
* the way Firestore would: each of `plan.filters` (==, !=, ranges, in,
|
|
22
|
+
* array-contains, array-contains-any, the document id) over fixture rows,
|
|
23
|
+
* the plan's one order, and pages of the requested size. A card spec mocks
|
|
24
|
+
* the hook with it:
|
|
25
|
+
*
|
|
26
|
+
* jest.mock('@aglyn/tenant-feature-instance/hooks/use-list-query', () =>
|
|
27
|
+
* require('@aglyn/tenant-feature-instance/testing/list-query-double')
|
|
28
|
+
* .listQueryModule(
|
|
29
|
+
* () => mockRows,
|
|
30
|
+
* jest.requireActual('@aglyn/tenant-feature-instance/hooks/use-list-query'),
|
|
31
|
+
* ))
|
|
32
|
+
*
|
|
33
|
+
* and asserts both what the plan asked for (`lastListQueryPlan()`) and which
|
|
34
|
+
* rows came back — so a spec can prove a match past the first page is found.
|
|
35
|
+
*
|
|
36
|
+
* ⛔ Never import this outside a spec. It is exactly the in-memory matching
|
|
37
|
+
* the lists stopped doing; a production path through it is the bug AGL-3321
|
|
38
|
+
* removed.
|
|
39
|
+
*/ import { nameSearchNormalizers } from "@aglyn/aglyn/app-utils/name-search";
|
|
40
|
+
import { LIST_QUERY_ID_PATH, planListQuery } from "@aglyn/shared-ui-jsx/const/list-query-plan";
|
|
41
|
+
import { useMemo, useState } from "react";
|
|
42
|
+
const read = (row, path)=>path === LIST_QUERY_ID_PATH ? row.$id : path.split('.').reduce((at, key)=>at == null ? undefined : at[key], row);
|
|
43
|
+
const comparable = (value)=>{
|
|
44
|
+
if (value instanceof Date) return value.getTime();
|
|
45
|
+
if (value && typeof value === 'object' && 'toMillis' in value) {
|
|
46
|
+
return value.toMillis();
|
|
47
|
+
}
|
|
48
|
+
if (value && typeof value === 'object' && 'seconds' in value) {
|
|
49
|
+
return value.seconds * 1000;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
};
|
|
53
|
+
/** Whether a row answers one predicate, as Firestore reads it. */ export function rowAnswers(row, filter) {
|
|
54
|
+
const held = read(row, filter.path);
|
|
55
|
+
const want = filter.value;
|
|
56
|
+
switch(filter.op){
|
|
57
|
+
case '==':
|
|
58
|
+
return want === null ? held === null : comparable(held) === comparable(want);
|
|
59
|
+
case '!=':
|
|
60
|
+
// Firestore's `!=` also requires the field to exist.
|
|
61
|
+
return held !== undefined && comparable(held) !== comparable(want);
|
|
62
|
+
case 'in':
|
|
63
|
+
return want.some((entry)=>comparable(entry) === comparable(held));
|
|
64
|
+
case 'array-contains':
|
|
65
|
+
return Array.isArray(held) && held.includes(want);
|
|
66
|
+
case 'array-contains-any':
|
|
67
|
+
return Array.isArray(held) && want.some((entry)=>held.includes(entry));
|
|
68
|
+
default:
|
|
69
|
+
{
|
|
70
|
+
if (held === undefined || held === null) return false;
|
|
71
|
+
const a = comparable(held);
|
|
72
|
+
const b = comparable(want);
|
|
73
|
+
if (filter.op === '<') return a < b;
|
|
74
|
+
if (filter.op === '<=') return a <= b;
|
|
75
|
+
if (filter.op === '>') return a > b;
|
|
76
|
+
return a >= b;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** The rows a plan's query returns, in its order — Firestore's answer, restated. */ export function answerListQuery(rows, plan) {
|
|
81
|
+
const { path, direction } = plan.orderBy;
|
|
82
|
+
return rows.filter((row)=>plan.filters.every((filter)=>rowAnswers(row, filter)))// `orderBy` drops a document missing the field it sorts by.
|
|
83
|
+
.filter((row)=>path === LIST_QUERY_ID_PATH || read(row, path) !== undefined).sort((left, right)=>{
|
|
84
|
+
const a = comparable(read(left, path));
|
|
85
|
+
const b = comparable(read(right, path));
|
|
86
|
+
const order = a < b ? -1 : a > b ? 1 : String(left.$id).localeCompare(String(right.$id));
|
|
87
|
+
return direction === 'desc' ? -order : order;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
let lastPlan = null;
|
|
91
|
+
/** The plan the double answered last, for a spec to assert what was asked. */ export const lastListQueryPlan = ()=>lastPlan;
|
|
92
|
+
/**
|
|
93
|
+
* `useListQuery`, answered from `rows()` instead of Firestore — same
|
|
94
|
+
* signature, same paging surface as `usePagedCollection`.
|
|
95
|
+
*/ export function useListQueryDouble(rows, options) {
|
|
96
|
+
var _options_pageSize;
|
|
97
|
+
const { declaration, request } = options;
|
|
98
|
+
const pageSizeOption = (_options_pageSize = options.pageSize) != null ? _options_pageSize : 10;
|
|
99
|
+
const [page, setPage] = useState(0);
|
|
100
|
+
const [pageSize, setPageSize] = useState(pageSizeOption);
|
|
101
|
+
const plan = useMemo(()=>planListQuery(declaration, request, nameSearchNormalizers), // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
[
|
|
103
|
+
declaration,
|
|
104
|
+
JSON.stringify(request)
|
|
105
|
+
]);
|
|
106
|
+
lastPlan = plan;
|
|
107
|
+
const matched = answerListQuery(rows(), plan);
|
|
108
|
+
const windowRows = matched.slice(0, pageSize * (page + 1) + 1);
|
|
109
|
+
return {
|
|
110
|
+
data: windowRows,
|
|
111
|
+
rows: matched.slice(page * pageSize, (page + 1) * pageSize),
|
|
112
|
+
hasMore: matched.length > pageSize * (page + 1),
|
|
113
|
+
page,
|
|
114
|
+
setPage,
|
|
115
|
+
pageSize,
|
|
116
|
+
setPageSize: (next)=>{
|
|
117
|
+
setPageSize(next);
|
|
118
|
+
setPage(0);
|
|
119
|
+
},
|
|
120
|
+
status: 'success',
|
|
121
|
+
error: undefined,
|
|
122
|
+
fromCache: false,
|
|
123
|
+
serverDenied: false,
|
|
124
|
+
plan
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A module to hand `jest.mock('…/hooks/use-list-query', …)`: the double in
|
|
129
|
+
* place of the hook, the real `listQueryConstraints` beside it.
|
|
130
|
+
*
|
|
131
|
+
* `actual` is the real module, which the spec's factory passes in as
|
|
132
|
+
* `jest.requireActual(…)`. Jest injects `jest` into each spec module rather
|
|
133
|
+
* than setting it on `globalThis`, so this file cannot reach it itself.
|
|
134
|
+
*/ export function listQueryModule(rows, actual) {
|
|
135
|
+
return _extends({}, actual, {
|
|
136
|
+
useListQuery: (options)=>useListQueryDouble(rows, options)
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
//# sourceMappingURL=list-query-double.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/testing/list-query-double.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/**\n * A TEST DOUBLE for `useListQuery` (AGL-3321) — for specs only.\n *\n * It runs the REAL `planListQuery` over the request, then answers the plan\n * the way Firestore would: each of `plan.filters` (==, !=, ranges, in,\n * array-contains, array-contains-any, the document id) over fixture rows,\n * the plan's one order, and pages of the requested size. A card spec mocks\n * the hook with it:\n *\n * jest.mock('@aglyn/tenant-feature-instance/hooks/use-list-query', () =>\n * require('@aglyn/tenant-feature-instance/testing/list-query-double')\n * .listQueryModule(\n * () => mockRows,\n * jest.requireActual('@aglyn/tenant-feature-instance/hooks/use-list-query'),\n * ))\n *\n * and asserts both what the plan asked for (`lastListQueryPlan()`) and which\n * rows came back — so a spec can prove a match past the first page is found.\n *\n * ⛔ Never import this outside a spec. It is exactly the in-memory matching\n * the lists stopped doing; a production path through it is the bug AGL-3321\n * removed.\n */\n\nimport { nameSearchNormalizers } from '@aglyn/aglyn/app-utils/name-search'\nimport {\n LIST_QUERY_ID_PATH,\n type ListQueryFilter,\n type ListQueryPlan,\n planListQuery,\n} from '@aglyn/shared-ui-jsx/const/list-query-plan'\nimport { useMemo, useState } from 'react'\nimport type { UseListQueryOptions, UseListQueryResult } from '../hooks/use-list-query'\n\ntype Row = Record<string, unknown> & { $id?: string }\n\nconst read = (row: Row, path: string): unknown =>\n path === LIST_QUERY_ID_PATH\n ? row.$id\n : path.split('.').reduce<unknown>(\n (at, key) => (at == null ? undefined : (at as Record<string, unknown>)[key]),\n row,\n )\n\nconst comparable = (value: unknown): unknown => {\n if (value instanceof Date) return value.getTime()\n if (value && typeof value === 'object' && 'toMillis' in value) {\n return (value as { toMillis(): number }).toMillis()\n }\n if (value && typeof value === 'object' && 'seconds' in value) {\n return (value as { seconds: number }).seconds * 1000\n }\n return value\n}\n\n/** Whether a row answers one predicate, as Firestore reads it. */\nexport function rowAnswers(row: Row, filter: ListQueryFilter): boolean {\n const held = read(row, filter.path)\n const want = filter.value\n switch (filter.op) {\n case '==':\n return want === null ? held === null : comparable(held) === comparable(want)\n case '!=':\n // Firestore's `!=` also requires the field to exist.\n return held !== undefined && comparable(held) !== comparable(want)\n case 'in':\n return (want as readonly unknown[]).some((entry) => comparable(entry) === comparable(held))\n case 'array-contains':\n return Array.isArray(held) && held.includes(want as never)\n case 'array-contains-any':\n return Array.isArray(held) && (want as readonly unknown[]).some((entry) => held.includes(entry))\n default: {\n if (held === undefined || held === null) return false\n const a = comparable(held) as number | string\n const b = comparable(want) as number | string\n if (filter.op === '<') return a < b\n if (filter.op === '<=') return a <= b\n if (filter.op === '>') return a > b\n return a >= b\n }\n }\n}\n\n/** The rows a plan's query returns, in its order — Firestore's answer, restated. */\nexport function answerListQuery<T extends Row>(rows: readonly T[], plan: ListQueryPlan): T[] {\n const { path, direction } = plan.orderBy\n return rows\n .filter((row) => plan.filters.every((filter) => rowAnswers(row, filter)))\n // `orderBy` drops a document missing the field it sorts by.\n .filter((row) => path === LIST_QUERY_ID_PATH || read(row, path) !== undefined)\n .sort((left, right) => {\n const a = comparable(read(left, path)) as number | string\n const b = comparable(read(right, path)) as number | string\n const order = a < b ? -1 : a > b ? 1 : String(left.$id).localeCompare(String(right.$id))\n return direction === 'desc' ? -order : order\n })\n}\n\nlet lastPlan: ListQueryPlan | null = null\n\n/** The plan the double answered last, for a spec to assert what was asked. */\nexport const lastListQueryPlan = (): ListQueryPlan | null => lastPlan\n\n/**\n * `useListQuery`, answered from `rows()` instead of Firestore — same\n * signature, same paging surface as `usePagedCollection`.\n */\nexport function useListQueryDouble<T = Row>(\n rows: () => readonly Row[],\n options: UseListQueryOptions,\n): UseListQueryResult<T> {\n const { declaration, request } = options\n const pageSizeOption = options.pageSize ?? 10\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(pageSizeOption)\n const plan = useMemo(\n () => planListQuery(declaration, request, nameSearchNormalizers),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [declaration, JSON.stringify(request)],\n )\n lastPlan = plan\n const matched = answerListQuery(rows(), plan)\n const windowRows = matched.slice(0, pageSize * (page + 1) + 1)\n return {\n data: windowRows as unknown as T[],\n rows: matched.slice(page * pageSize, (page + 1) * pageSize) as unknown as T[],\n hasMore: matched.length > pageSize * (page + 1),\n page,\n setPage,\n pageSize,\n setPageSize: (next: number) => {\n setPageSize(next)\n setPage(0)\n },\n status: 'success',\n error: undefined,\n fromCache: false,\n serverDenied: false,\n plan,\n } as unknown as UseListQueryResult<T>\n}\n\n/**\n * A module to hand `jest.mock('…/hooks/use-list-query', …)`: the double in\n * place of the hook, the real `listQueryConstraints` beside it.\n *\n * `actual` is the real module, which the spec's factory passes in as\n * `jest.requireActual(…)`. Jest injects `jest` into each spec module rather\n * than setting it on `globalThis`, so this file cannot reach it itself.\n */\nexport function listQueryModule(rows: () => readonly Row[], actual: object) {\n return {\n ...actual,\n useListQuery: (options: UseListQueryOptions) => useListQueryDouble(rows, options),\n }\n}\n"],"names":["nameSearchNormalizers","LIST_QUERY_ID_PATH","planListQuery","useMemo","useState","read","row","path","$id","split","reduce","at","key","undefined","comparable","value","Date","getTime","toMillis","seconds","rowAnswers","filter","held","want","op","some","entry","Array","isArray","includes","a","b","answerListQuery","rows","plan","direction","orderBy","filters","every","sort","left","right","order","String","localeCompare","lastPlan","lastListQueryPlan","useListQueryDouble","options","declaration","request","pageSizeOption","pageSize","page","setPage","setPageSize","JSON","stringify","matched","windowRows","slice","data","hasMore","length","next","status","error","fromCache","serverDenied","listQueryModule","actual","useListQuery"],"mappings":"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"}
|