@aglyn/tenant-feature-instance 1.0.0-beta.199 → 1.0.0-beta.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- 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/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
|
},
|
|
@@ -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,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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"}
|