@aglyn/tenant-feature-instance 1.0.0-beta.275 → 1.0.0-beta.282

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-feature-instance",
3
- "version": "1.0.0-beta.275",
3
+ "version": "1.0.0-beta.282",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,14 +25,15 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.275",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.275",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.275",
31
- "@aglyn/shared-util-email": "1.0.0-beta.275",
32
- "@aglyn/shared-util-http": "1.0.0-beta.275",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.275",
28
+ "@aglyn/aglyn": "1.0.0-beta.282",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.282",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.282",
31
+ "@aglyn/shared-util-email": "1.0.0-beta.282",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.282",
33
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.282",
34
34
  "@swc/helpers": "0.5.23",
35
- "lodash-es": "^4.18.1"
35
+ "lodash-es": "^4.18.1",
36
+ "mobx": "^7.0.5"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "@mui/material": "^9.3.1",
@@ -0,0 +1,73 @@
1
+ import { type Firestore } from 'firebase/firestore';
2
+ export interface ConfirmedDocState<T> {
3
+ /** The doc's data (with `$id`), or undefined while loading / when absent. */
4
+ data: T | undefined;
5
+ /**
6
+ * True once an answer worth acting on has arrived: any snapshot that has
7
+ * data, or a MISS the server itself has confirmed. Consumers must not
8
+ * 404, fall back to a default, or otherwise treat "no doc" as real until
9
+ * this is true.
10
+ */
11
+ ready: boolean;
12
+ /** True only when retries were exhausted — offer a retry, never a 404. */
13
+ error: boolean;
14
+ /**
15
+ * `data` is being served from the local cache and the server has not
16
+ * confirmed it (AGL-1066). Never a reason to withhold the value — only a
17
+ * reason not to let it decide something irreversible.
18
+ */
19
+ fromCache: boolean;
20
+ }
21
+ export interface UseConfirmedDocOptions {
22
+ /**
23
+ * Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).
24
+ *
25
+ * The base contract trusts any snapshot that exists, whatever its source.
26
+ * That is right for ordinary content — the value was legitimately the
27
+ * user's a moment ago, and blanking it costs more than it buys. It is NOT
28
+ * right for the entitlement doc: `orgs/{orgId}` is the only plan source
29
+ * (AGL-238), and a cached copy from before a plan change renders the org
30
+ * at the wrong tier for as long as the session stays stale. AGL-887 closed
31
+ * the tombstone direction of exactly this fault (a Business workspace
32
+ * rendering as Free); this closes the stale-EXISTS direction.
33
+ *
34
+ * Opt-in because it can cost one extra server read, and only when the
35
+ * listener has actually failed to reach the server — see the deferral in
36
+ * the implementation. Enable it for entitlement-shaped documents only.
37
+ */
38
+ confirmCachedHit?: boolean;
39
+ }
40
+ /** Test seam — the in-flight map is module scope by design. */
41
+ export declare function resetConfirmedDocSharing(): void;
42
+ /**
43
+ * A doc listen that never presents an unconfirmed miss (AGL-928).
44
+ *
45
+ * This is the shared form of the contract four consumers learned the hard
46
+ * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB
47
+ * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and
48
+ * a resumed listen never re-sends the correction — so `exists() === false`
49
+ * from cache looks confirmed while being wrong. Symptoms ranged from valid
50
+ * hosts 404ing to a Business workspace rendering as Free.
51
+ *
52
+ * Contract implemented here:
53
+ * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:
54
+ * right after sign-in (and on cold loads behind App Check) the first
55
+ * listen can sit in transient `permission-denied` before the ID token
56
+ * attaches (AGL-216); reactfire's cached Observable would replay that
57
+ * error forever.
58
+ * - A snapshot that EXISTS is trusted immediately, whatever its source.
59
+ * - A snapshot that is MISSING and came from cache is confirmed via
60
+ * `getDocFromServer` before it is believed; a failed confirm leaves
61
+ * `ready` false rather than lying.
62
+ * - Exhausted retries set `error` (and `ready`) so consumers can offer
63
+ * "try again" — never a false 404 or a fallback default.
64
+ *
65
+ * For QUERY listens the same contract lives in `useOrgHosts` (empty →
66
+ * `getDocsFromServer` confirm); extract it the same way if a third query
67
+ * consumer appears.
68
+ *
69
+ * @param segments Doc path segments (`['orgs', orgId]`), or null to idle
70
+ * (state resets while null, matching "clear on scope change").
71
+ */
72
+ export declare function useConfirmedDoc<T extends Record<string, unknown>>(firestore: Firestore, segments: readonly [string, ...string[]] | null, options?: UseConfirmedDocOptions): ConfirmedDocState<T>;
73
+ export default useConfirmedDoc;
@@ -0,0 +1,204 @@
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 { doc, getDocFromServer, onSnapshot } from "firebase/firestore";
19
+ import { useEffect, useState } from "react";
20
+ const RETRY_DELAY_MS = 400;
21
+ const MAX_RETRIES = 8;
22
+ const MAX_RETRY_DELAY_MS = 15000;
23
+ /**
24
+ * How long a cached hit is allowed to stand before it is confirmed.
25
+ *
26
+ * Not zero, and this is the whole reason the option is affordable: on a
27
+ * healthy load the first snapshot is routinely from cache and the server
28
+ * snapshot follows within milliseconds. Confirming immediately would bill an
29
+ * extra read on every mount for every user; waiting means the confirm fires
30
+ * only when the server genuinely did not answer.
31
+ */ const CONFIRM_CACHED_HIT_DELAY_MS = 1500;
32
+ /**
33
+ * Confirms in flight, keyed by document path (AGL-1440).
34
+ *
35
+ * `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts
36
+ * it seven-plus times per page — user menu, org switcher, branding, and three
37
+ * separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly
38
+ * free, because the SDK serves identical listens off ONE target; the confirms
39
+ * are not. Each instance scheduled its own `getDocFromServer`, so a console
40
+ * page load billed seven server reads of `orgs/{orgId}` to answer one question.
41
+ *
42
+ * Sharing the PROMISE, rather than suppressing the duplicates, is what keeps
43
+ * this behaviour-preserving: every instance still resolves from a real server
44
+ * snapshot and still calls `deliver(..., fromCache: false)`, so none of them is
45
+ * left displaying a value flagged unconfirmed. Suppressing instead would leave
46
+ * the six quiet instances on `fromCache: true` forever, which is a state
47
+ * consumers are told means "do not let this decide anything irreversible".
48
+ *
49
+ * The entry is dropped as soon as it settles, so this is a burst collapser and
50
+ * nothing more: a mount two seconds later asks the server again, exactly as it
51
+ * does today. NOTHING about the entitlement is cached — this shares one READ
52
+ * of the org document; every consumer evaluates the plan off it as before.
53
+ */ const confirmsInFlight = new Map();
54
+ /** The server's copy of `ref`, shared with any confirm already in flight. */ function sharedConfirm(pathKey, ref) {
55
+ const existing = confirmsInFlight.get(pathKey);
56
+ if (existing) return existing;
57
+ const pending = getDocFromServer(ref).finally(()=>{
58
+ confirmsInFlight.delete(pathKey);
59
+ });
60
+ confirmsInFlight.set(pathKey, pending);
61
+ return pending;
62
+ }
63
+ /** Test seam — the in-flight map is module scope by design. */ export function resetConfirmedDocSharing() {
64
+ confirmsInFlight.clear();
65
+ }
66
+ /**
67
+ * A doc listen that never presents an unconfirmed miss (AGL-928).
68
+ *
69
+ * This is the shared form of the contract four consumers learned the hard
70
+ * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB
71
+ * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and
72
+ * a resumed listen never re-sends the correction — so `exists() === false`
73
+ * from cache looks confirmed while being wrong. Symptoms ranged from valid
74
+ * hosts 404ing to a Business workspace rendering as Free.
75
+ *
76
+ * Contract implemented here:
77
+ * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:
78
+ * right after sign-in (and on cold loads behind App Check) the first
79
+ * listen can sit in transient `permission-denied` before the ID token
80
+ * attaches (AGL-216); reactfire's cached Observable would replay that
81
+ * error forever.
82
+ * - A snapshot that EXISTS is trusted immediately, whatever its source.
83
+ * - A snapshot that is MISSING and came from cache is confirmed via
84
+ * `getDocFromServer` before it is believed; a failed confirm leaves
85
+ * `ready` false rather than lying.
86
+ * - Exhausted retries set `error` (and `ready`) so consumers can offer
87
+ * "try again" — never a false 404 or a fallback default.
88
+ *
89
+ * For QUERY listens the same contract lives in `useOrgHosts` (empty →
90
+ * `getDocsFromServer` confirm); extract it the same way if a third query
91
+ * consumer appears.
92
+ *
93
+ * @param segments Doc path segments (`['orgs', orgId]`), or null to idle
94
+ * (state resets while null, matching "clear on scope change").
95
+ */ export function useConfirmedDoc(firestore, segments, options = {}) {
96
+ const [state, setState] = useState({
97
+ data: undefined,
98
+ ready: false,
99
+ error: false,
100
+ fromCache: true
101
+ });
102
+ // Effect key: a new path must tear down and restart the listen.
103
+ const pathKey = segments ? segments.join('/') : null;
104
+ const confirmCachedHit = options.confirmCachedHit;
105
+ useEffect(()=>{
106
+ setState({
107
+ data: undefined,
108
+ ready: false,
109
+ error: false,
110
+ fromCache: true
111
+ });
112
+ if (!pathKey) return;
113
+ let cancelled = false;
114
+ let unsubscribe = null;
115
+ let timer = null;
116
+ let attempt = 0;
117
+ // One confirm per mount, and never once the server has answered.
118
+ let confirmTimer = null;
119
+ let confirmRequested = false;
120
+ const ref = doc(firestore, pathKey);
121
+ const deliver = (exists, id, payload, fromCache)=>setState({
122
+ data: exists ? _extends({
123
+ $id: id
124
+ }, payload) : undefined,
125
+ ready: true,
126
+ error: false,
127
+ fromCache
128
+ });
129
+ const subscribe = ()=>{
130
+ unsubscribe = onSnapshot(ref, (snapshot)=>{
131
+ if (cancelled) return;
132
+ attempt = 0;
133
+ const fromCache = snapshot.metadata.fromCache;
134
+ if (!fromCache && confirmTimer) {
135
+ // The server answered on its own; nothing left to confirm.
136
+ clearTimeout(confirmTimer);
137
+ confirmTimer = null;
138
+ }
139
+ if (!snapshot.exists() && fromCache) {
140
+ // Unconfirmed miss: ask the server before believing it. On
141
+ // failure stay un-ready — the listener remains subscribed and
142
+ // the next (server) snapshot resolves it.
143
+ void sharedConfirm(pathKey, ref).then((server)=>{
144
+ if (cancelled) return;
145
+ deliver(server.exists(), server.id, server.data(), false);
146
+ }).catch(()=>undefined);
147
+ return;
148
+ }
149
+ deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache);
150
+ /**
151
+ * Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.
152
+ *
153
+ * The value is already delivered above and stays on screen
154
+ * whatever happens here — this never blanks the page and never
155
+ * holds `ready` false, because a transient blip must not become a
156
+ * wrong tier OR a 404. It only replaces a cached answer with the
157
+ * server's when the server can be reached.
158
+ */ if (confirmCachedHit && fromCache && snapshot.exists() && !confirmRequested && !confirmTimer) {
159
+ confirmTimer = setTimeout(()=>{
160
+ confirmTimer = null;
161
+ confirmRequested = true;
162
+ void sharedConfirm(pathKey, ref).then((server)=>{
163
+ if (cancelled) return;
164
+ deliver(server.exists(), server.id, server.data(), false);
165
+ })// Could not reach the server: keep showing the cached value,
166
+ // still flagged `fromCache`. Refusing to answer would be the
167
+ // stale-`ready` false-404 this hook exists to prevent.
168
+ .catch(()=>undefined);
169
+ }, CONFIRM_CACHED_HIT_DELAY_MS);
170
+ }
171
+ }, ()=>{
172
+ if (cancelled) return;
173
+ unsubscribe == null ? void 0 : unsubscribe();
174
+ if (attempt < MAX_RETRIES) {
175
+ const delay = Math.min(RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
176
+ attempt += 1;
177
+ timer = setTimeout(subscribe, delay);
178
+ } else {
179
+ setState({
180
+ data: undefined,
181
+ ready: true,
182
+ error: true,
183
+ fromCache: true
184
+ });
185
+ }
186
+ });
187
+ };
188
+ subscribe();
189
+ return ()=>{
190
+ cancelled = true;
191
+ if (timer) clearTimeout(timer);
192
+ if (confirmTimer) clearTimeout(confirmTimer);
193
+ unsubscribe == null ? void 0 : unsubscribe();
194
+ };
195
+ }, [
196
+ firestore,
197
+ pathKey,
198
+ confirmCachedHit
199
+ ]);
200
+ return state;
201
+ }
202
+ export default useConfirmedDoc;
203
+
204
+ //# sourceMappingURL=use-confirmed-doc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-confirmed-doc.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 {\n doc,\n getDocFromServer,\n onSnapshot,\n type DocumentData,\n type DocumentReference,\n type DocumentSnapshot,\n type Firestore,\n} from 'firebase/firestore'\nimport { useEffect, useState } from 'react'\n\nconst RETRY_DELAY_MS = 400\nconst MAX_RETRIES = 8\nconst MAX_RETRY_DELAY_MS = 15_000\n\nexport interface ConfirmedDocState<T> {\n /** The doc's data (with `$id`), or undefined while loading / when absent. */\n data: T | undefined\n /**\n * True once an answer worth acting on has arrived: any snapshot that has\n * data, or a MISS the server itself has confirmed. Consumers must not\n * 404, fall back to a default, or otherwise treat \"no doc\" as real until\n * this is true.\n */\n ready: boolean\n /** True only when retries were exhausted — offer a retry, never a 404. */\n error: boolean\n /**\n * `data` is being served from the local cache and the server has not\n * confirmed it (AGL-1066). Never a reason to withhold the value — only a\n * reason not to let it decide something irreversible.\n */\n fromCache: boolean\n}\n\nexport interface UseConfirmedDocOptions {\n /**\n * Also confirm a cache-served hit that EXISTS, not just a miss (AGL-1066).\n *\n * The base contract trusts any snapshot that exists, whatever its source.\n * That is right for ordinary content — the value was legitimately the\n * user's a moment ago, and blanking it costs more than it buys. It is NOT\n * right for the entitlement doc: `orgs/{orgId}` is the only plan source\n * (AGL-238), and a cached copy from before a plan change renders the org\n * at the wrong tier for as long as the session stays stale. AGL-887 closed\n * the tombstone direction of exactly this fault (a Business workspace\n * rendering as Free); this closes the stale-EXISTS direction.\n *\n * Opt-in because it can cost one extra server read, and only when the\n * listener has actually failed to reach the server — see the deferral in\n * the implementation. Enable it for entitlement-shaped documents only.\n */\n confirmCachedHit?: boolean\n}\n\n/**\n * How long a cached hit is allowed to stand before it is confirmed.\n *\n * Not zero, and this is the whole reason the option is affordable: on a\n * healthy load the first snapshot is routinely from cache and the server\n * snapshot follows within milliseconds. Confirming immediately would bill an\n * extra read on every mount for every user; waiting means the confirm fires\n * only when the server genuinely did not answer.\n */\nconst CONFIRM_CACHED_HIT_DELAY_MS = 1_500\n\n/**\n * Confirms in flight, keyed by document path (AGL-1440).\n *\n * `useCurrentOrg` is a plain hook, not a context, and the console chrome mounts\n * it seven-plus times per page — user menu, org switcher, branding, and three\n * separate paths through `ConsolePluginsGate`. The `onSnapshot`s are nearly\n * free, because the SDK serves identical listens off ONE target; the confirms\n * are not. Each instance scheduled its own `getDocFromServer`, so a console\n * page load billed seven server reads of `orgs/{orgId}` to answer one question.\n *\n * Sharing the PROMISE, rather than suppressing the duplicates, is what keeps\n * this behaviour-preserving: every instance still resolves from a real server\n * snapshot and still calls `deliver(..., fromCache: false)`, so none of them is\n * left displaying a value flagged unconfirmed. Suppressing instead would leave\n * the six quiet instances on `fromCache: true` forever, which is a state\n * consumers are told means \"do not let this decide anything irreversible\".\n *\n * The entry is dropped as soon as it settles, so this is a burst collapser and\n * nothing more: a mount two seconds later asks the server again, exactly as it\n * does today. NOTHING about the entitlement is cached — this shares one READ\n * of the org document; every consumer evaluates the plan off it as before.\n */\nconst confirmsInFlight = new Map<\n string,\n Promise<DocumentSnapshot<DocumentData>>\n>()\n\n/** The server's copy of `ref`, shared with any confirm already in flight. */\nfunction sharedConfirm(\n pathKey: string,\n ref: DocumentReference,\n): Promise<DocumentSnapshot<DocumentData>> {\n const existing = confirmsInFlight.get(pathKey)\n if (existing) return existing\n const pending = getDocFromServer(ref).finally(() => {\n confirmsInFlight.delete(pathKey)\n })\n confirmsInFlight.set(pathKey, pending)\n return pending\n}\n\n/** Test seam — the in-flight map is module scope by design. */\nexport function resetConfirmedDocSharing(): void {\n confirmsInFlight.clear()\n}\n\n/**\n * A doc listen that never presents an unconfirmed miss (AGL-928).\n *\n * This is the shared form of the contract four consumers learned the hard\n * way (AGL-813/827/875/886/887): the console's multi-tab IndexedDB\n * persistence can serve a stale `noDocument` tombstone for a LIVE doc, and\n * a resumed listen never re-sends the correction — so `exists() === false`\n * from cache looks confirmed while being wrong. Symptoms ranged from valid\n * hosts 404ing to a Business workspace rendering as Free.\n *\n * Contract implemented here:\n * - Subscribe with a raw `onSnapshot` + exponential-backoff resubscribe:\n * right after sign-in (and on cold loads behind App Check) the first\n * listen can sit in transient `permission-denied` before the ID token\n * attaches (AGL-216); reactfire's cached Observable would replay that\n * error forever.\n * - A snapshot that EXISTS is trusted immediately, whatever its source.\n * - A snapshot that is MISSING and came from cache is confirmed via\n * `getDocFromServer` before it is believed; a failed confirm leaves\n * `ready` false rather than lying.\n * - Exhausted retries set `error` (and `ready`) so consumers can offer\n * \"try again\" — never a false 404 or a fallback default.\n *\n * For QUERY listens the same contract lives in `useOrgHosts` (empty →\n * `getDocsFromServer` confirm); extract it the same way if a third query\n * consumer appears.\n *\n * @param segments Doc path segments (`['orgs', orgId]`), or null to idle\n * (state resets while null, matching \"clear on scope change\").\n */\nexport function useConfirmedDoc<T extends Record<string, unknown>>(\n firestore: Firestore,\n segments: readonly [string, ...string[]] | null,\n options: UseConfirmedDocOptions = {},\n): ConfirmedDocState<T> {\n const [state, setState] = useState<ConfirmedDocState<T>>({\n data: undefined,\n ready: false,\n error: false,\n fromCache: true,\n })\n // Effect key: a new path must tear down and restart the listen.\n const pathKey = segments ? segments.join('/') : null\n const confirmCachedHit = options.confirmCachedHit\n\n useEffect(() => {\n setState({ data: undefined, ready: false, error: false, fromCache: true })\n if (!pathKey) return\n\n let cancelled = false\n let unsubscribe: (() => void) | null = null\n let timer: ReturnType<typeof setTimeout> | null = null\n let attempt = 0\n // One confirm per mount, and never once the server has answered.\n let confirmTimer: ReturnType<typeof setTimeout> | null = null\n let confirmRequested = false\n const ref = doc(firestore, pathKey)\n\n const deliver = (\n exists: boolean,\n id: string,\n payload: Record<string, unknown> | undefined,\n fromCache: boolean,\n ) =>\n setState({\n data: exists ? ({ $id: id, ...payload } as unknown as T) : undefined,\n ready: true,\n error: false,\n fromCache,\n })\n\n const subscribe = () => {\n unsubscribe = onSnapshot(\n ref,\n (snapshot) => {\n if (cancelled) return\n attempt = 0\n const fromCache = snapshot.metadata.fromCache\n if (!fromCache && confirmTimer) {\n // The server answered on its own; nothing left to confirm.\n clearTimeout(confirmTimer)\n confirmTimer = null\n }\n if (!snapshot.exists() && fromCache) {\n // Unconfirmed miss: ask the server before believing it. On\n // failure stay un-ready — the listener remains subscribed and\n // the next (server) snapshot resolves it.\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n .catch(() => undefined)\n return\n }\n deliver(snapshot.exists(), snapshot.id, snapshot.data(), fromCache)\n\n /**\n * Unconfirmed HIT (AGL-1066), for entitlement-shaped docs only.\n *\n * The value is already delivered above and stays on screen\n * whatever happens here — this never blanks the page and never\n * holds `ready` false, because a transient blip must not become a\n * wrong tier OR a 404. It only replaces a cached answer with the\n * server's when the server can be reached.\n */\n if (\n confirmCachedHit &&\n fromCache &&\n snapshot.exists() &&\n !confirmRequested &&\n !confirmTimer\n ) {\n confirmTimer = setTimeout(() => {\n confirmTimer = null\n confirmRequested = true\n void sharedConfirm(pathKey, ref)\n .then((server) => {\n if (cancelled) return\n deliver(server.exists(), server.id, server.data(), false)\n })\n // Could not reach the server: keep showing the cached value,\n // still flagged `fromCache`. Refusing to answer would be the\n // stale-`ready` false-404 this hook exists to prevent.\n .catch(() => undefined)\n }, CONFIRM_CACHED_HIT_DELAY_MS)\n }\n },\n () => {\n if (cancelled) return\n unsubscribe?.()\n if (attempt < MAX_RETRIES) {\n const delay = Math.min(\n RETRY_DELAY_MS * 2 ** attempt,\n MAX_RETRY_DELAY_MS,\n )\n attempt += 1\n timer = setTimeout(subscribe, delay)\n } else {\n setState({\n data: undefined,\n ready: true,\n error: true,\n fromCache: true,\n })\n }\n },\n )\n }\n subscribe()\n\n return () => {\n cancelled = true\n if (timer) clearTimeout(timer)\n if (confirmTimer) clearTimeout(confirmTimer)\n unsubscribe?.()\n }\n }, [firestore, pathKey, confirmCachedHit])\n\n return state\n}\n\nexport default useConfirmedDoc\n"],"names":["doc","getDocFromServer","onSnapshot","useEffect","useState","RETRY_DELAY_MS","MAX_RETRIES","MAX_RETRY_DELAY_MS","CONFIRM_CACHED_HIT_DELAY_MS","confirmsInFlight","Map","sharedConfirm","pathKey","ref","existing","get","pending","finally","delete","set","resetConfirmedDocSharing","clear","useConfirmedDoc","firestore","segments","options","state","setState","data","undefined","ready","error","fromCache","join","confirmCachedHit","cancelled","unsubscribe","timer","attempt","confirmTimer","confirmRequested","deliver","exists","id","payload","$id","subscribe","snapshot","metadata","clearTimeout","then","server","catch","setTimeout","delay","Math","min"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import type * as Aglyn from '@aglyn/aglyn';
2
+ export interface UseHostComponentDefinitionsResult {
3
+ /** Raw definition docs, `$id` included, tombstones and all. */
4
+ docs: Aglyn.AglynHostComponent[];
5
+ /**
6
+ * Live definitions keyed by id in the shape
7
+ * `composeReusableComponentNodes` consumes, or `undefined` until the
8
+ * first snapshot settles — see the callers for why "loading" and "this
9
+ * host has none" must not look the same.
10
+ */
11
+ definitions: Record<string, Aglyn.ReusableComponentTree> | undefined;
12
+ }
13
+ /**
14
+ * Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the
15
+ * host's reusable-component definitions, read through ONE query and shared
16
+ * by everything in the editor that needs them (AGL-1217).
17
+ *
18
+ * "One query", not "read once": `useFirestoreCollection` is a live
19
+ * `onSnapshot` listener, and that is load-bearing rather than incidental.
20
+ * It is the entire transport for propagating a component edit into already
21
+ * open besigners (AGL-1898) — publish a component in one tab and every
22
+ * canvas rendering an instance of it re-renders, because this map is a new
23
+ * object and the consumers memo on it. Swapping this for a one-shot read to
24
+ * "save reads" would silently take that away, and nothing would fail: the
25
+ * canvas would simply keep drawing a stale component until reloaded.
26
+ *
27
+ * Note the boundary. This watches the component's PARENT doc, which only
28
+ * Publish writes; the component editor's Save writes the version doc and is
29
+ * invisible here.
30
+ *
31
+ * One hook rather than a read per consumer so the console asks the same
32
+ * question the same way — identical `Query`, so the SDK serves every caller
33
+ * from a single listener — and so the skip rules stay in one place. A doc
34
+ * missing `rootId`/`nodes` is unpublished, not broken; skipping it here is
35
+ * what keeps the canvas agreeing with the live site about which instances
36
+ * expand. Fail-open like the server loader: an error yields an empty map,
37
+ * leaving instances as-is rather than blanking the document.
38
+ */
39
+ export declare function useHostComponentDefinitions(hostId: string | undefined): UseHostComponentDefinitionsResult;
40
+ export default useHostComponentDefinitions;
@@ -0,0 +1,92 @@
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
+ // Deep path, keeping the type-only barrel import above erased.
19
+ import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
20
+ import { useFirestore } from "./firebase/firebase-services.js";
21
+ import { collection, limit, query } from "firebase/firestore";
22
+ import { useMemo } from "react";
23
+ import useFirestoreCollection from "./use-firestore-collection.js";
24
+ /**
25
+ * Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the
26
+ * host's reusable-component definitions, read through ONE query and shared
27
+ * by everything in the editor that needs them (AGL-1217).
28
+ *
29
+ * "One query", not "read once": `useFirestoreCollection` is a live
30
+ * `onSnapshot` listener, and that is load-bearing rather than incidental.
31
+ * It is the entire transport for propagating a component edit into already
32
+ * open besigners (AGL-1898) — publish a component in one tab and every
33
+ * canvas rendering an instance of it re-renders, because this map is a new
34
+ * object and the consumers memo on it. Swapping this for a one-shot read to
35
+ * "save reads" would silently take that away, and nothing would fail: the
36
+ * canvas would simply keep drawing a stale component until reloaded.
37
+ *
38
+ * Note the boundary. This watches the component's PARENT doc, which only
39
+ * Publish writes; the component editor's Save writes the version doc and is
40
+ * invisible here.
41
+ *
42
+ * One hook rather than a read per consumer so the console asks the same
43
+ * question the same way — identical `Query`, so the SDK serves every caller
44
+ * from a single listener — and so the skip rules stay in one place. A doc
45
+ * missing `rootId`/`nodes` is unpublished, not broken; skipping it here is
46
+ * what keeps the canvas agreeing with the live site about which instances
47
+ * expand. Fail-open like the server loader: an error yields an empty map,
48
+ * leaving instances as-is rather than blanking the document.
49
+ */ export function useHostComponentDefinitions(hostId) {
50
+ const firestore = useFirestore();
51
+ const { data, status } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'components'), limit(200)) : null, [
52
+ firestore,
53
+ hostId
54
+ ], {
55
+ idField: '$id'
56
+ });
57
+ const definitions = useMemo(()=>{
58
+ if (status === 'loading') return undefined;
59
+ const next = {};
60
+ for (const value of data != null ? data : []){
61
+ var _value_props, _value_icon;
62
+ if ((value == null ? void 0 : value.deletedAt) || !(value == null ? void 0 : value.nodes) || !(value == null ? void 0 : value.rootId)) continue;
63
+ // BOTH stored forms (AGL-1151). This is a raw collection query, so
64
+ // `nodes` arrives exactly as stored — msgpack for anything promoted
65
+ // since components were compressed, a plain map for everything older.
66
+ // The canvas grafts every instance from this map, so a `Bytes` reaching
67
+ // it blanks every placed component in the editor while the live site
68
+ // still renders them.
69
+ const nodes = decodeStoredNodes(value.nodes);
70
+ if (!nodes) continue;
71
+ next[value.$id] = _extends({
72
+ rootId: value.rootId,
73
+ nodes
74
+ }, ((_value_props = value.props) == null ? void 0 : _value_props.length) && {
75
+ props: value.props
76
+ }, ((_value_icon = value.icon) == null ? void 0 : _value_icon.iconPath) && {
77
+ icon: value.icon
78
+ });
79
+ }
80
+ return next;
81
+ }, [
82
+ data,
83
+ status
84
+ ]);
85
+ return {
86
+ docs: data != null ? data : [],
87
+ definitions
88
+ };
89
+ }
90
+ export default useHostComponentDefinitions;
91
+
92
+ //# sourceMappingURL=use-host-component-definitions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-component-definitions.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 type * as Aglyn from '@aglyn/aglyn'\n// Deep path, keeping the type-only barrel import above erased.\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { useFirestore } from './firebase/firebase-services'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport useFirestoreCollection from './use-firestore-collection'\n\nexport interface UseHostComponentDefinitionsResult {\n /** Raw definition docs, `$id` included, tombstones and all. */\n docs: Aglyn.AglynHostComponent[]\n /**\n * Live definitions keyed by id in the shape\n * `composeReusableComponentNodes` consumes, or `undefined` until the\n * first snapshot settles — see the callers for why \"loading\" and \"this\n * host has none\" must not look the same.\n */\n definitions: Record<string, Aglyn.ReusableComponentTree> | undefined\n}\n\n/**\n * Client-side twin of `libs/tenant/runtime/src/lib/get-components.ts`: the\n * host's reusable-component definitions, read through ONE query and shared\n * by everything in the editor that needs them (AGL-1217).\n *\n * \"One query\", not \"read once\": `useFirestoreCollection` is a live\n * `onSnapshot` listener, and that is load-bearing rather than incidental.\n * It is the entire transport for propagating a component edit into already\n * open besigners (AGL-1898) — publish a component in one tab and every\n * canvas rendering an instance of it re-renders, because this map is a new\n * object and the consumers memo on it. Swapping this for a one-shot read to\n * \"save reads\" would silently take that away, and nothing would fail: the\n * canvas would simply keep drawing a stale component until reloaded.\n *\n * Note the boundary. This watches the component's PARENT doc, which only\n * Publish writes; the component editor's Save writes the version doc and is\n * invisible here.\n *\n * One hook rather than a read per consumer so the console asks the same\n * question the same way — identical `Query`, so the SDK serves every caller\n * from a single listener — and so the skip rules stay in one place. A doc\n * missing `rootId`/`nodes` is unpublished, not broken; skipping it here is\n * what keeps the canvas agreeing with the live site about which instances\n * expand. Fail-open like the server loader: an error yields an empty map,\n * leaving instances as-is rather than blanking the document.\n */\nexport function useHostComponentDefinitions(\n hostId: string | undefined,\n): UseHostComponentDefinitionsResult {\n const firestore = useFirestore()\n const { data, status } = useFirestoreCollection<Aglyn.AglynHostComponent>(\n () =>\n hostId\n ? query(collection(firestore, 'hosts', hostId, 'components'), limit(200))\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const definitions = useMemo(() => {\n if (status === 'loading') return undefined\n const next: Record<string, Aglyn.ReusableComponentTree> = {}\n for (const value of data ?? []) {\n if (value?.deletedAt || !value?.nodes || !value?.rootId) continue\n // BOTH stored forms (AGL-1151). This is a raw collection query, so\n // `nodes` arrives exactly as stored — msgpack for anything promoted\n // since components were compressed, a plain map for everything older.\n // The canvas grafts every instance from this map, so a `Bytes` reaching\n // it blanks every placed component in the editor while the live site\n // still renders them.\n const nodes = decodeStoredNodes<Aglyn.ReusableComponentTree['nodes']>(\n value.nodes,\n )\n if (!nodes) continue\n next[value.$id] = {\n rootId: value.rootId,\n nodes,\n // Declared props ride along (AGL-1247) so the editor's graft\n // substitutes the same values the live site will.\n ...(value.props?.length && { props: value.props }),\n // As does the chosen icon (AGL-1193) — the hierarchy row and canvas\n // badge draw it from here, so an instance's icon needs no read of\n // its own.\n ...(value.icon?.iconPath && { icon: value.icon }),\n }\n }\n return next\n }, [data, status])\n\n return { docs: data ?? [], definitions }\n}\n\nexport default useHostComponentDefinitions\n"],"names":["decodeStoredNodes","useFirestore","collection","limit","query","useMemo","useFirestoreCollection","useHostComponentDefinitions","hostId","firestore","data","status","idField","definitions","undefined","next","value","deletedAt","nodes","rootId","$id","props","length","icon","iconPath","docs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAGA,+DAA+D;AAC/D,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,OAAOC,4BAA4B,gCAA4B;AAc/D;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,SAASC,4BACdC,MAA0B;IAE1B,MAAMC,YAAYR;IAClB,MAAM,EAAES,IAAI,EAAEC,MAAM,EAAE,GAAGL,uBACvB,IACEE,SACIJ,MAAMF,WAAWO,WAAW,SAASD,QAAQ,eAAeL,MAAM,QAClE,MACN;QAACM;QAAWD;KAAO,EACnB;QAAEI,SAAS;IAAM;IAGnB,MAAMC,cAAcR,QAAQ;QAC1B,IAAIM,WAAW,WAAW,OAAOG;QACjC,MAAMC,OAAoD,CAAC;QAC3D,KAAK,MAAMC,SAASN,eAAAA,OAAQ,EAAE,CAAE;gBAiBxBM,cAIAA;YApBN,IAAIA,CAAAA,yBAAAA,MAAOC,SAAS,KAAI,EAACD,yBAAAA,MAAOE,KAAK,KAAI,EAACF,yBAAAA,MAAOG,MAAM,GAAE;YACzD,mEAAmE;YACnE,oEAAoE;YACpE,sEAAsE;YACtE,wEAAwE;YACxE,qEAAqE;YACrE,sBAAsB;YACtB,MAAMD,QAAQlB,kBACZgB,MAAME,KAAK;YAEb,IAAI,CAACA,OAAO;YACZH,IAAI,CAACC,MAAMI,GAAG,CAAC,GAAG;gBAChBD,QAAQH,MAAMG,MAAM;gBACpBD;eAGIF,EAAAA,eAAAA,MAAMK,KAAK,qBAAXL,aAAaM,MAAM,KAAI;gBAAED,OAAOL,MAAMK,KAAK;YAAC,GAI5CL,EAAAA,cAAAA,MAAMO,IAAI,qBAAVP,YAAYQ,QAAQ,KAAI;gBAAED,MAAMP,MAAMO,IAAI;YAAC;QAEnD;QACA,OAAOR;IACT,GAAG;QAACL;QAAMC;KAAO;IAEjB,OAAO;QAAEc,IAAI,EAAEf,eAAAA,OAAQ,EAAE;QAAEG;IAAY;AACzC;AAEA,eAAeN,4BAA2B"}
@@ -0,0 +1,42 @@
1
+ import type * as Aglyn from '@aglyn/aglyn';
2
+ /** A form document as the query hands it back, with its id stamped on. */
3
+ type FormDocumentWithId = Aglyn.FormDocument & {
4
+ $id: string;
5
+ };
6
+ export interface UseHostFormDesignsResult {
7
+ /** Raw form docs, `$id` included, unpublished ones and all. */
8
+ docs: FormDocumentWithId[];
9
+ /**
10
+ * The published design of each form, keyed by id, or `undefined` until the
11
+ * first snapshot settles — "loading" and "this host has none" must not look
12
+ * the same, or the canvas paints the page's own fields and swaps them for
13
+ * the entity's a beat later.
14
+ */
15
+ designs: Record<string, Aglyn.PlacedFormDesign> | undefined;
16
+ }
17
+ /**
18
+ * Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's
19
+ * form entities read through ONE query, so a placed form draws its entity's
20
+ * fields on the canvas exactly as the published page will
21
+ * (`docs/specs/reusable-forms.md`).
22
+ *
23
+ * A sibling of `use-host-component-definitions.ts` in every respect that
24
+ * matters, because the two answer the same question about different documents.
25
+ * That includes the live `onSnapshot`, which is load-bearing rather than
26
+ * incidental: it is the whole transport for propagating a form publish into
27
+ * already-open screen besigners. A one-shot read would "save reads" and
28
+ * silently leave every open canvas drawing a stale form until reloaded.
29
+ *
30
+ * Note the boundary, the same one the components hook draws: this watches the
31
+ * form's PARENT doc, which only Publish writes. The form besigner's Save
32
+ * writes the version doc and is invisible here — a screen must not show fields
33
+ * the live site has never had.
34
+ *
35
+ * A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,
36
+ * is unpublished rather than broken; skipping it is what keeps the canvas
37
+ * agreeing with the live site about which placements resolve. Fail-open like
38
+ * the server loader: an error yields an empty map, leaving each placement
39
+ * rendering its own fields rather than blanking it.
40
+ */
41
+ export declare function useHostFormDesigns(hostId: string | undefined): UseHostFormDesignsResult;
42
+ export default useHostFormDesigns;
@@ -0,0 +1,84 @@
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
+ // Deep path, keeping the type-only barrel import above erased.
18
+ import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
19
+ import { useFirestore } from "./firebase/firebase-services.js";
20
+ import { collection, limit, query } from "firebase/firestore";
21
+ import { useMemo } from "react";
22
+ import useFirestoreCollection from "./use-firestore-collection.js";
23
+ /** Matches the tenant render read, so the canvas cannot see more than a page. */ const FORMS_PER_CANVAS = 200;
24
+ /**
25
+ * Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's
26
+ * form entities read through ONE query, so a placed form draws its entity's
27
+ * fields on the canvas exactly as the published page will
28
+ * (`docs/specs/reusable-forms.md`).
29
+ *
30
+ * A sibling of `use-host-component-definitions.ts` in every respect that
31
+ * matters, because the two answer the same question about different documents.
32
+ * That includes the live `onSnapshot`, which is load-bearing rather than
33
+ * incidental: it is the whole transport for propagating a form publish into
34
+ * already-open screen besigners. A one-shot read would "save reads" and
35
+ * silently leave every open canvas drawing a stale form until reloaded.
36
+ *
37
+ * Note the boundary, the same one the components hook draws: this watches the
38
+ * form's PARENT doc, which only Publish writes. The form besigner's Save
39
+ * writes the version doc and is invisible here — a screen must not show fields
40
+ * the live site has never had.
41
+ *
42
+ * A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,
43
+ * is unpublished rather than broken; skipping it is what keeps the canvas
44
+ * agreeing with the live site about which placements resolve. Fail-open like
45
+ * the server loader: an error yields an empty map, leaving each placement
46
+ * rendering its own fields rather than blanking it.
47
+ */ export function useHostFormDesigns(hostId) {
48
+ const firestore = useFirestore();
49
+ const { data, status } = useFirestoreCollection(()=>hostId ? query(collection(firestore, 'hosts', hostId, 'forms'), limit(FORMS_PER_CANVAS)) : null, [
50
+ firestore,
51
+ hostId
52
+ ], {
53
+ idField: '$id'
54
+ });
55
+ const designs = useMemo(()=>{
56
+ if (status === 'loading') return undefined;
57
+ const next = {};
58
+ for (const value of data != null ? data : []){
59
+ if (!(value == null ? void 0 : value.nodes) || !(value == null ? void 0 : value.rootId)) continue;
60
+ // BOTH stored forms (AGL-1151). This is a raw collection query, so
61
+ // `nodes` arrives exactly as stored — msgpack for anything published
62
+ // since compression landed, a plain map for anything older. A `Bytes`
63
+ // reaching the graft has no `rootId` entry, so every placed form on the
64
+ // canvas would draw empty while the live site still renders it.
65
+ const nodes = decodeStoredNodes(value.nodes);
66
+ if (!(nodes == null ? void 0 : nodes[value.rootId])) continue;
67
+ next[value.$id] = {
68
+ rootId: value.rootId,
69
+ nodes
70
+ };
71
+ }
72
+ return next;
73
+ }, [
74
+ data,
75
+ status
76
+ ]);
77
+ return {
78
+ docs: data != null ? data : [],
79
+ designs
80
+ };
81
+ }
82
+ export default useHostFormDesigns;
83
+
84
+ //# sourceMappingURL=use-host-form-designs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-host-form-designs.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 type * as Aglyn from '@aglyn/aglyn'\n// Deep path, keeping the type-only barrel import above erased.\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { useFirestore } from './firebase/firebase-services'\nimport { collection, limit, query } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport useFirestoreCollection from './use-firestore-collection'\n\n/** Matches the tenant render read, so the canvas cannot see more than a page. */\nconst FORMS_PER_CANVAS = 200\n\n/** A form document as the query hands it back, with its id stamped on. */\ntype FormDocumentWithId = Aglyn.FormDocument & { $id: string }\n\nexport interface UseHostFormDesignsResult {\n /** Raw form docs, `$id` included, unpublished ones and all. */\n docs: FormDocumentWithId[]\n /**\n * The published design of each form, keyed by id, or `undefined` until the\n * first snapshot settles — \"loading\" and \"this host has none\" must not look\n * the same, or the canvas paints the page's own fields and swaps them for\n * the entity's a beat later.\n */\n designs: Record<string, Aglyn.PlacedFormDesign> | undefined\n}\n\n/**\n * Client-side twin of `libs/tenant/runtime/src/lib/get-forms.ts`: the host's\n * form entities read through ONE query, so a placed form draws its entity's\n * fields on the canvas exactly as the published page will\n * (`docs/specs/reusable-forms.md`).\n *\n * A sibling of `use-host-component-definitions.ts` in every respect that\n * matters, because the two answer the same question about different documents.\n * That includes the live `onSnapshot`, which is load-bearing rather than\n * incidental: it is the whole transport for propagating a form publish into\n * already-open screen besigners. A one-shot read would \"save reads\" and\n * silently leave every open canvas drawing a stale form until reloaded.\n *\n * Note the boundary, the same one the components hook draws: this watches the\n * form's PARENT doc, which only Publish writes. The form besigner's Save\n * writes the version doc and is invisible here — a screen must not show fields\n * the live site has never had.\n *\n * A doc with no `rootId`/`nodes`, or whose root is missing from its own nodes,\n * is unpublished rather than broken; skipping it is what keeps the canvas\n * agreeing with the live site about which placements resolve. Fail-open like\n * the server loader: an error yields an empty map, leaving each placement\n * rendering its own fields rather than blanking it.\n */\nexport function useHostFormDesigns(\n hostId: string | undefined,\n): UseHostFormDesignsResult {\n const firestore = useFirestore()\n const { data, status } = useFirestoreCollection<FormDocumentWithId>(\n () =>\n hostId\n ? query(\n collection(firestore, 'hosts', hostId, 'forms'),\n limit(FORMS_PER_CANVAS),\n )\n : null,\n [firestore, hostId],\n { idField: '$id' },\n )\n\n const designs = useMemo(() => {\n if (status === 'loading') return undefined\n const next: Record<string, Aglyn.PlacedFormDesign> = {}\n for (const value of data ?? []) {\n if (!value?.nodes || !value?.rootId) continue\n // BOTH stored forms (AGL-1151). This is a raw collection query, so\n // `nodes` arrives exactly as stored — msgpack for anything published\n // since compression landed, a plain map for anything older. A `Bytes`\n // reaching the graft has no `rootId` entry, so every placed form on the\n // canvas would draw empty while the live site still renders it.\n const nodes = decodeStoredNodes<\n NonNullable<Aglyn.FormDocument['nodes']>\n >(value.nodes)\n if (!nodes?.[value.rootId]) continue\n next[value.$id] = { rootId: value.rootId, nodes }\n }\n return next\n }, [data, status])\n\n return { docs: data ?? [], designs }\n}\n\nexport default useHostFormDesigns\n"],"names":["decodeStoredNodes","useFirestore","collection","limit","query","useMemo","useFirestoreCollection","FORMS_PER_CANVAS","useHostFormDesigns","hostId","firestore","data","status","idField","designs","undefined","next","value","nodes","rootId","$id","docs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,+DAA+D;AAC/D,SAASA,iBAAiB,QAAQ,sCAAqC;AACvE,SAASC,YAAY,QAAQ,kCAA8B;AAC3D,SAASC,UAAU,EAAEC,KAAK,EAAEC,KAAK,QAAQ,qBAAoB;AAC7D,SAASC,OAAO,QAAQ,QAAO;AAC/B,OAAOC,4BAA4B,gCAA4B;AAE/D,+EAA+E,GAC/E,MAAMC,mBAAmB;AAiBzB;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,SAASC,mBACdC,MAA0B;IAE1B,MAAMC,YAAYT;IAClB,MAAM,EAAEU,IAAI,EAAEC,MAAM,EAAE,GAAGN,uBACvB,IACEG,SACIL,MACEF,WAAWQ,WAAW,SAASD,QAAQ,UACvCN,MAAMI,qBAER,MACN;QAACG;QAAWD;KAAO,EACnB;QAAEI,SAAS;IAAM;IAGnB,MAAMC,UAAUT,QAAQ;QACtB,IAAIO,WAAW,WAAW,OAAOG;QACjC,MAAMC,OAA+C,CAAC;QACtD,KAAK,MAAMC,SAASN,eAAAA,OAAQ,EAAE,CAAE;YAC9B,IAAI,EAACM,yBAAAA,MAAOC,KAAK,KAAI,EAACD,yBAAAA,MAAOE,MAAM,GAAE;YACrC,mEAAmE;YACnE,qEAAqE;YACrE,sEAAsE;YACtE,wEAAwE;YACxE,gEAAgE;YAChE,MAAMD,QAAQlB,kBAEZiB,MAAMC,KAAK;YACb,IAAI,EAACA,yBAAAA,KAAO,CAACD,MAAME,MAAM,CAAC,GAAE;YAC5BH,IAAI,CAACC,MAAMG,GAAG,CAAC,GAAG;gBAAED,QAAQF,MAAME,MAAM;gBAAED;YAAM;QAClD;QACA,OAAOF;IACT,GAAG;QAACL;QAAMC;KAAO;IAEjB,OAAO;QAAES,IAAI,EAAEV,eAAAA,OAAQ,EAAE;QAAEG;IAAQ;AACrC;AAEA,eAAeN,mBAAkB"}