@aglyn/tenant-data-admin 1.0.0-beta.197 → 1.0.0-beta.199

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.197",
3
+ "version": "1.0.0-beta.199",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.197",
29
- "@aglyn/shared-util-email": "1.0.0-beta.197",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.197",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.197",
32
- "@aglyn/shared-util-http": "1.0.0-beta.197",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.197",
28
+ "@aglyn/aglyn": "1.0.0-beta.199",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.199",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.199",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.199",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.199",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.199",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.3",
@@ -0,0 +1,83 @@
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 { Firestore } from 'firebase-admin/firestore';
18
+ import type { DatasetLivePageTarget } from './dataset-live-pages';
19
+ /**
20
+ * How many documents per collection the placement scan reads.
21
+ *
22
+ * The number every other cache-dropping scan uses, for their reason
23
+ * (AGL-1161): this decides which caches get dropped, so a prefix scan reports
24
+ * a refreshed site and leaves real pages stale. When the bound bites it is
25
+ * returned as `truncated`, never absorbed.
26
+ */
27
+ export declare const COLLECTION_SCAN_LIMIT = 2000;
28
+ export interface CollectionLivePageScope {
29
+ /**
30
+ * Site-absolute addresses derived from the collection's slug — the entry
31
+ * pages, the listing and its pages, each category listing — most important
32
+ * first, because the tenant's path cap keeps the FIRST paths it is handed.
33
+ */
34
+ paths: string[];
35
+ /** Screens that render the collection somewhere else, by id. */
36
+ screenIds: string[];
37
+ /** The placement scan read fewer documents than the site holds. */
38
+ truncated: boolean;
39
+ }
40
+ /**
41
+ * What an entry change makes stale, for one content collection.
42
+ *
43
+ * A content entry is not published through a version pointer, so nothing here
44
+ * looks like the publish paths above: the write is a client Firestore write,
45
+ * and the page that renders it is reached by ADDRESS rather than by a screen
46
+ * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and
47
+ * `/blog/my-post` are served by the catch-all's collection fallback, which may
48
+ * have no screen of its own at all, so a routing-map lookup finds nothing to
49
+ * drop and the site keeps serving the old post.
50
+ *
51
+ * Two halves, therefore, and both are needed:
52
+ *
53
+ * - the collection's own ADDRESSES, derived here from its slug;
54
+ * - the SCREENS that render the collection somewhere else — a rail on the
55
+ * home page, category pills in a layout — which are found by searching node
56
+ * trees, exactly as a form's placements are.
57
+ *
58
+ * Refuses anything that is not a CONTENT collection. Commerce shares
59
+ * `hosts/{hostId}/collections`, and a product collection's pages are routed by
60
+ * the store's templates rather than by these shapes, so building content
61
+ * addresses from one would drop paths that belong to nothing.
62
+ */
63
+ export declare function collectionLivePageScope(options: {
64
+ firestore: Firestore;
65
+ hostId: string;
66
+ collectionId: string;
67
+ entrySlugs: readonly string[];
68
+ }): Promise<CollectionLivePageScope>;
69
+ /**
70
+ * The same scope as ONE site's drop target: the collection's addresses with
71
+ * the dependent screens resolved through the routing map, in the shape a
72
+ * `LivePageDropper` takes.
73
+ *
74
+ * `null` when there is nothing to drop — a site with no subdomain has no
75
+ * tenant deployment holding pages, and a collection that is gone or is the
76
+ * store's has no content addresses.
77
+ */
78
+ export declare function collectionLivePageTarget(options: {
79
+ firestore: Firestore;
80
+ hostId: string;
81
+ collectionId: string;
82
+ entrySlugs: readonly string[];
83
+ }): Promise<DatasetLivePageTarget | null>;
@@ -0,0 +1,198 @@
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
+ * Which live pages a content collection's change makes stale (AGL-3340).
19
+ *
20
+ * Two callers ask, from two runtimes. The console asks when somebody saves,
21
+ * publishes, schedules, re-dates or deletes an entry, and announces the answer
22
+ * to the tenant over the shared secret. The tenant asks when a SCHEDULED entry
23
+ * comes due, which is a change nobody makes: no write from the console, no
24
+ * request, only the clock — so the tenant's own beat has to find the pages and
25
+ * drop them in process. Two copies of this answer are how a scheduled post
26
+ * would come to reach `/blog` and miss the home page's rail, so it is here and
27
+ * both import it.
28
+ *
29
+ * Reads only. Announcing is the caller's, for the reason
30
+ * `dataset-live-pages.ts` gives: the two runtimes drop caches differently.
31
+ */ import { COLLECTION_CATEGORIES_MAX, COLLECTION_LIST_PAGE_SIZE, COLLECTION_SOURCE_MAX, collectionCategorySlug, collectionListUrl, hostCollectionKind, screenRoutePathToUrl } from "@aglyn/aglyn/server";
32
+ import { readUsageSources, screenIdsUsingCollectionDeep } from "./live-page-usage.js";
33
+ /**
34
+ * How many documents per collection the placement scan reads.
35
+ *
36
+ * The number every other cache-dropping scan uses, for their reason
37
+ * (AGL-1161): this decides which caches get dropped, so a prefix scan reports
38
+ * a refreshed site and leaves real pages stale. When the bound bites it is
39
+ * returned as `truncated`, never absorbed.
40
+ */ export const COLLECTION_SCAN_LIMIT = 2000;
41
+ /**
42
+ * What an entry change makes stale, for one content collection.
43
+ *
44
+ * A content entry is not published through a version pointer, so nothing here
45
+ * looks like the publish paths above: the write is a client Firestore write,
46
+ * and the page that renders it is reached by ADDRESS rather than by a screen
47
+ * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and
48
+ * `/blog/my-post` are served by the catch-all's collection fallback, which may
49
+ * have no screen of its own at all, so a routing-map lookup finds nothing to
50
+ * drop and the site keeps serving the old post.
51
+ *
52
+ * Two halves, therefore, and both are needed:
53
+ *
54
+ * - the collection's own ADDRESSES, derived here from its slug;
55
+ * - the SCREENS that render the collection somewhere else — a rail on the
56
+ * home page, category pills in a layout — which are found by searching node
57
+ * trees, exactly as a form's placements are.
58
+ *
59
+ * Refuses anything that is not a CONTENT collection. Commerce shares
60
+ * `hosts/{hostId}/collections`, and a product collection's pages are routed by
61
+ * the store's templates rather than by these shapes, so building content
62
+ * addresses from one would drop paths that belong to nothing.
63
+ */ export async function collectionLivePageScope(options) {
64
+ var _collectionSnapshot_data, _data_slug;
65
+ const { firestore, hostId, collectionId, entrySlugs } = options;
66
+ const empty = {
67
+ paths: [],
68
+ screenIds: [],
69
+ truncated: false
70
+ };
71
+ const collectionSnapshot = await firestore.collection('hosts').doc(hostId).collection('collections').doc(collectionId).get();
72
+ if (!collectionSnapshot.exists) return empty;
73
+ const data = (_collectionSnapshot_data = collectionSnapshot.data()) != null ? _collectionSnapshot_data : {};
74
+ if (hostCollectionKind(data) !== 'content') return empty;
75
+ if (data['deletedAt']) return empty;
76
+ const collectionSlug = String((_data_slug = data['slug']) != null ? _data_slug : '').trim();
77
+ if (!collectionSlug || collectionSlug.includes('/')) return empty;
78
+ /**
79
+ * Ordered by how much each address matters, because the tenant's path cap
80
+ * takes the FIRST `MAX_PATHS` it is handed. A site whose collection is
81
+ * rendered on more pages than the cap admits therefore loses its deepest
82
+ * category listings rather than the post that was just edited.
83
+ */ const paths = [];
84
+ const add = (path)=>{
85
+ if (path && !paths.includes(path)) paths.push(path);
86
+ };
87
+ // The entry's own address first — the one page whose author is watching.
88
+ // Both slugs when a save renamed it: the new address has never been
89
+ // rendered, and the OLD one is a cached page that now belongs to nothing.
90
+ for (const entrySlug of entrySlugs)add(`/${collectionSlug}/${entrySlug}`);
91
+ add(collectionListUrl({
92
+ collectionSlug
93
+ }));
94
+ /**
95
+ * Every page of the unfiltered listing that the CACHED read covers.
96
+ *
97
+ * A constant range rather than a count-derived one: dropping a page that
98
+ * does not exist is a cache-key delete against a key nothing holds, which
99
+ * costs nothing, while asking Firestore for the exact page count would trade
100
+ * that for a read on every save and still be wrong the moment publishing an
101
+ * entry adds a page.
102
+ *
103
+ * It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the
104
+ * LIVE SET and not merely one read of it — a listing had no eleventh page to
105
+ * miss. AGL-3213 ended that: the constant still bounds the cached head every
106
+ * listing address shares, but the collection now runs past it, and a page
107
+ * that starts beyond the head is served by its own window read at its own
108
+ * address.
109
+ *
110
+ * The range deliberately did not follow it there. `MAX_PATHS` takes the
111
+ * FIRST paths it is handed, so extending this to a collection's real page
112
+ * count would spend the budget on pages almost nobody opens and drop the
113
+ * dependent screens below instead — and those deep pages are exactly the
114
+ * ones whose entries did not change. They catch up on their own ISR window,
115
+ * which is the argument the category listings below already make for
116
+ * themselves.
117
+ *
118
+ * Since AGL-3219 there is nothing for them to catch up ON. A deep page is
119
+ * addressed by the entry it continues from, so publishing at the head of
120
+ * the collection does not change which entries it holds — a stale cursor
121
+ * page is merely old, never wrong. It was POSITIONAL addressing that made
122
+ * this lag visible: the head refreshed here, the tail did not, and the two
123
+ * then disagreed by exactly the one entry that had been inserted above
124
+ * them both.
125
+ */ const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE);
126
+ for(let page = 2; page <= listPages; page += 1){
127
+ add(collectionListUrl({
128
+ collectionSlug,
129
+ page
130
+ }));
131
+ }
132
+ /**
133
+ * Page one of every category listing.
134
+ *
135
+ * Every category rather than the changed entry's, because an entry can move
136
+ * between two in one save and a delete leaves no entry to ask — so the set
137
+ * that is certainly right is the collection's own, and it is bounded by
138
+ * `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each
139
+ * category is `COLLECTION_CATEGORIES_MAX` times as many paths as the
140
+ * unfiltered listing, which would push the cap over on the categories alone
141
+ * and take the dependent screens with it. Deeper category pages catch up on
142
+ * their own ISR window.
143
+ */ const categories = Array.isArray(data['categories']) ? data['categories'] : [];
144
+ for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)){
145
+ var _ref;
146
+ const name = String((_ref = category == null ? void 0 : category.name) != null ? _ref : '').trim();
147
+ if (!name) continue;
148
+ const categorySlug = collectionCategorySlug(name);
149
+ if (!categorySlug) continue;
150
+ add(collectionListUrl({
151
+ collectionSlug,
152
+ categorySlug
153
+ }));
154
+ }
155
+ const sources = await readUsageSources(firestore.collection('hosts').doc(hostId), COLLECTION_SCAN_LIMIT);
156
+ return {
157
+ paths,
158
+ screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),
159
+ truncated: sources.truncated
160
+ };
161
+ }
162
+ /**
163
+ * The same scope as ONE site's drop target: the collection's addresses with
164
+ * the dependent screens resolved through the routing map, in the shape a
165
+ * `LivePageDropper` takes.
166
+ *
167
+ * `null` when there is nothing to drop — a site with no subdomain has no
168
+ * tenant deployment holding pages, and a collection that is gone or is the
169
+ * store's has no content addresses.
170
+ */ export async function collectionLivePageTarget(options) {
171
+ var _hostSnapshot_get, _hostSnapshot_get1, _hostSnapshot_get2;
172
+ const { firestore, hostId } = options;
173
+ const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
174
+ if (!hostSnapshot.exists) return null;
175
+ const subdomain = String((_hostSnapshot_get = hostSnapshot.get('subdomain')) != null ? _hostSnapshot_get : '');
176
+ if (!subdomain) return null;
177
+ const scope = await collectionLivePageScope(options);
178
+ const screens = (_hostSnapshot_get1 = hostSnapshot.get('screens')) != null ? _hostSnapshot_get1 : {};
179
+ // The derived addresses lead, as they do on the console route: they are the
180
+ // pages the change is about, and the cap keeps what it is handed first.
181
+ const paths = [
182
+ ...scope.paths,
183
+ ...scope.screenIds.map((screenId)=>screens[screenId]).filter((path)=>Boolean(path)).map((path)=>screenRoutePathToUrl(path))
184
+ ].filter((path, index, all)=>all.indexOf(path) === index);
185
+ if (!paths.length) return null;
186
+ const cname = String((_hostSnapshot_get2 = hostSnapshot.get('cname')) != null ? _hostSnapshot_get2 : '');
187
+ return _extends({
188
+ hostId,
189
+ subdomain
190
+ }, cname ? {
191
+ cname
192
+ } : {}, {
193
+ paths,
194
+ truncated: scope.truncated
195
+ });
196
+ }
197
+
198
+ //# sourceMappingURL=collection-live-pages.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/collection-live-pages.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 * Which live pages a content collection's change makes stale (AGL-3340).\n *\n * Two callers ask, from two runtimes. The console asks when somebody saves,\n * publishes, schedules, re-dates or deletes an entry, and announces the answer\n * to the tenant over the shared secret. The tenant asks when a SCHEDULED entry\n * comes due, which is a change nobody makes: no write from the console, no\n * request, only the clock — so the tenant's own beat has to find the pages and\n * drop them in process. Two copies of this answer are how a scheduled post\n * would come to reach `/blog` and miss the home page's rail, so it is here and\n * both import it.\n *\n * Reads only. Announcing is the caller's, for the reason\n * `dataset-live-pages.ts` gives: the two runtimes drop caches differently.\n */\n\nimport {\n COLLECTION_CATEGORIES_MAX,\n COLLECTION_LIST_PAGE_SIZE,\n COLLECTION_SOURCE_MAX,\n collectionCategorySlug,\n collectionListUrl,\n hostCollectionKind,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport type { DatasetLivePageTarget } from './dataset-live-pages'\nimport {\n readUsageSources,\n screenIdsUsingCollectionDeep,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the placement scan reads.\n *\n * The number every other cache-dropping scan uses, for their reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan reports\n * a refreshed site and leaves real pages stale. When the bound bites it is\n * returned as `truncated`, never absorbed.\n */\nexport const COLLECTION_SCAN_LIMIT = 2000\n\nexport interface CollectionLivePageScope {\n /**\n * Site-absolute addresses derived from the collection's slug — the entry\n * pages, the listing and its pages, each category listing — most important\n * first, because the tenant's path cap keeps the FIRST paths it is handed.\n */\n paths: string[]\n /** Screens that render the collection somewhere else, by id. */\n screenIds: string[]\n /** The placement scan read fewer documents than the site holds. */\n truncated: boolean\n}\n\n/**\n * What an entry change makes stale, for one content collection.\n *\n * A content entry is not published through a version pointer, so nothing here\n * looks like the publish paths above: the write is a client Firestore write,\n * and the page that renders it is reached by ADDRESS rather than by a screen\n * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and\n * `/blog/my-post` are served by the catch-all's collection fallback, which may\n * have no screen of its own at all, so a routing-map lookup finds nothing to\n * drop and the site keeps serving the old post.\n *\n * Two halves, therefore, and both are needed:\n *\n * - the collection's own ADDRESSES, derived here from its slug;\n * - the SCREENS that render the collection somewhere else — a rail on the\n * home page, category pills in a layout — which are found by searching node\n * trees, exactly as a form's placements are.\n *\n * Refuses anything that is not a CONTENT collection. Commerce shares\n * `hosts/{hostId}/collections`, and a product collection's pages are routed by\n * the store's templates rather than by these shapes, so building content\n * addresses from one would drop paths that belong to nothing.\n */\nexport async function collectionLivePageScope(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<CollectionLivePageScope> {\n const { firestore, hostId, collectionId, entrySlugs } = options\n const empty: CollectionLivePageScope = {\n paths: [],\n screenIds: [],\n truncated: false,\n }\n const collectionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .doc(collectionId)\n .get()\n if (!collectionSnapshot.exists) return empty\n const data = collectionSnapshot.data() ?? {}\n if (hostCollectionKind(data) !== 'content') return empty\n if (data['deletedAt']) return empty\n const collectionSlug = String(data['slug'] ?? '').trim()\n if (!collectionSlug || collectionSlug.includes('/')) return empty\n\n /**\n * Ordered by how much each address matters, because the tenant's path cap\n * takes the FIRST `MAX_PATHS` it is handed. A site whose collection is\n * rendered on more pages than the cap admits therefore loses its deepest\n * category listings rather than the post that was just edited.\n */\n const paths: string[] = []\n const add = (path: string) => {\n if (path && !paths.includes(path)) paths.push(path)\n }\n\n // The entry's own address first — the one page whose author is watching.\n // Both slugs when a save renamed it: the new address has never been\n // rendered, and the OLD one is a cached page that now belongs to nothing.\n for (const entrySlug of entrySlugs) add(`/${collectionSlug}/${entrySlug}`)\n add(collectionListUrl({ collectionSlug }))\n\n /**\n * Every page of the unfiltered listing that the CACHED read covers.\n *\n * A constant range rather than a count-derived one: dropping a page that\n * does not exist is a cache-key delete against a key nothing holds, which\n * costs nothing, while asking Firestore for the exact page count would trade\n * that for a read on every save and still be wrong the moment publishing an\n * entry adds a page.\n *\n * It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the\n * LIVE SET and not merely one read of it — a listing had no eleventh page to\n * miss. AGL-3213 ended that: the constant still bounds the cached head every\n * listing address shares, but the collection now runs past it, and a page\n * that starts beyond the head is served by its own window read at its own\n * address.\n *\n * The range deliberately did not follow it there. `MAX_PATHS` takes the\n * FIRST paths it is handed, so extending this to a collection's real page\n * count would spend the budget on pages almost nobody opens and drop the\n * dependent screens below instead — and those deep pages are exactly the\n * ones whose entries did not change. They catch up on their own ISR window,\n * which is the argument the category listings below already make for\n * themselves.\n *\n * Since AGL-3219 there is nothing for them to catch up ON. A deep page is\n * addressed by the entry it continues from, so publishing at the head of\n * the collection does not change which entries it holds — a stale cursor\n * page is merely old, never wrong. It was POSITIONAL addressing that made\n * this lag visible: the head refreshed here, the tail did not, and the two\n * then disagreed by exactly the one entry that had been inserted above\n * them both.\n */\n const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE)\n for (let page = 2; page <= listPages; page += 1) {\n add(collectionListUrl({ collectionSlug, page }))\n }\n\n /**\n * Page one of every category listing.\n *\n * Every category rather than the changed entry's, because an entry can move\n * between two in one save and a delete leaves no entry to ask — so the set\n * that is certainly right is the collection's own, and it is bounded by\n * `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each\n * category is `COLLECTION_CATEGORIES_MAX` times as many paths as the\n * unfiltered listing, which would push the cap over on the categories alone\n * and take the dependent screens with it. Deeper category pages catch up on\n * their own ISR window.\n */\n const categories = Array.isArray(data['categories']) ? data['categories'] : []\n for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)) {\n const name = String((category as { name?: unknown })?.name ?? '').trim()\n if (!name) continue\n const categorySlug = collectionCategorySlug(name)\n if (!categorySlug) continue\n add(collectionListUrl({ collectionSlug, categorySlug }))\n }\n\n const sources = await readUsageSources(\n firestore.collection('hosts').doc(hostId),\n COLLECTION_SCAN_LIMIT,\n )\n return {\n paths,\n screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),\n truncated: sources.truncated,\n }\n}\n\n/**\n * The same scope as ONE site's drop target: the collection's addresses with\n * the dependent screens resolved through the routing map, in the shape a\n * `LivePageDropper` takes.\n *\n * `null` when there is nothing to drop — a site with no subdomain has no\n * tenant deployment holding pages, and a collection that is gone or is the\n * store's has no content addresses.\n */\nexport async function collectionLivePageTarget(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<DatasetLivePageTarget | null> {\n const { firestore, hostId } = options\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n if (!subdomain) return null\n\n const scope = await collectionLivePageScope(options)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n // The derived addresses lead, as they do on the console route: they are the\n // pages the change is about, and the cap keeps what it is handed first.\n const paths = [\n ...scope.paths,\n ...scope.screenIds\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path)),\n ].filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: scope.truncated,\n }\n}\n"],"names":["COLLECTION_CATEGORIES_MAX","COLLECTION_LIST_PAGE_SIZE","COLLECTION_SOURCE_MAX","collectionCategorySlug","collectionListUrl","hostCollectionKind","screenRoutePathToUrl","readUsageSources","screenIdsUsingCollectionDeep","COLLECTION_SCAN_LIMIT","collectionLivePageScope","options","collectionSnapshot","data","firestore","hostId","collectionId","entrySlugs","empty","paths","screenIds","truncated","collection","doc","get","exists","collectionSlug","String","trim","includes","add","path","push","entrySlug","listPages","Math","ceil","page","categories","Array","isArray","category","slice","name","categorySlug","sources","candidates","collectionLivePageTarget","hostSnapshot","subdomain","scope","screens","map","screenId","filter","Boolean","index","all","indexOf","length","cname"],"mappings":"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"}
@@ -203,6 +203,39 @@ export declare function screenIdsUsingLayoutDeep(layoutId: string, screens: Usag
203
203
  * allows, and a cycle here would hang a publish rather than surface anything.
204
204
  */
205
205
  export declare function screenIdsUsingComponentDeep(componentId: string, sources: UsageSources): string[];
206
+ /**
207
+ * Every live screen whose rendered output renders the collection
208
+ * `collectionSlug`, however indirectly.
209
+ *
210
+ * The content twin of the console's `screenIdsUsingFormDeep`, and the same
211
+ * walk for the same reason. Saving, publishing or deleting an ENTRY changes
212
+ * every page that renders the collection it belongs to, and only some of those
213
+ * pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and
214
+ * `/blog/category/guides` are; the "Latest posts" rail on the home page, the
215
+ * category pills in a layout's chrome and the search box in a header are not
216
+ * — they are found by searching node trees, and they can sit on a screen,
217
+ * inside page chrome, or inside a reusable component that a third component
218
+ * nests.
219
+ *
220
+ * Miss them and an entry edit reports success while the pages that advertise
221
+ * it keep serving the old set for the whole revalidate window — with the rail
222
+ * on the home page, the single most visible place the change was supposed to
223
+ * appear.
224
+ *
225
+ * The first level is the only thing that differs from the form walk: a
226
+ * document uses a collection by BINDING a collection block to its slug. After
227
+ * that the fan-out is identical, so it is delegated rather than restated.
228
+ *
229
+ * The collection's OWN routes are deliberately not here. They are addresses,
230
+ * not screens — a fallback listing has no screen document at all, and a
231
+ * template screen serves the collection's paths rather than its own — so the
232
+ * caller derives them from the slug and sends them beside these.
233
+ *
234
+ * Pure, and separate from the Firestore read, so the closure is testable
235
+ * without a database. Cycle-safe by delegation: both walks it defers to bound
236
+ * themselves.
237
+ */
238
+ export declare function screenIdsUsingCollectionDeep(collectionSlug: string, sources: UsageSources): string[];
206
239
  export interface UsageCandidateRead {
207
240
  candidates: UsageCandidate[];
208
241
  /**
@@ -30,7 +30,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
30
30
  * Nothing here is dataset-specific, and nothing is console-specific. The
31
31
  * console's `scan-artifact-usage` re-exports these and keeps the parts that
32
32
  * are about the console's own "what would I break" copy.
33
- */ import { nodesReferenceComponent, decodeStoredNodes } from "@aglyn/aglyn/server";
33
+ */ import { nodesReferenceComponent, nodesRenderCollection, decodeStoredNodes } from "@aglyn/aglyn/server";
34
34
  import { getSystemEmailTemplate, SYSTEM_EMAIL_COLLECTION } from "@aglyn/shared-util-email/system-email-catalog";
35
35
  import { getTenantEmail, TENANT_EMAIL_COLLECTION } from "@aglyn/shared-util-email/tenant-email-catalog";
36
36
  /** `displayName`, falling back to a legacy `name`, then the raw id. */ export function usageCandidateLabel(candidate) {
@@ -226,6 +226,66 @@ import { getTenantEmail, TENANT_EMAIL_COLLECTION } from "@aglyn/shared-util-emai
226
226
  ...screenIds
227
227
  ];
228
228
  }
229
+ /**
230
+ * Every live screen whose rendered output renders the collection
231
+ * `collectionSlug`, however indirectly.
232
+ *
233
+ * The content twin of the console's `screenIdsUsingFormDeep`, and the same
234
+ * walk for the same reason. Saving, publishing or deleting an ENTRY changes
235
+ * every page that renders the collection it belongs to, and only some of those
236
+ * pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and
237
+ * `/blog/category/guides` are; the "Latest posts" rail on the home page, the
238
+ * category pills in a layout's chrome and the search box in a header are not
239
+ * — they are found by searching node trees, and they can sit on a screen,
240
+ * inside page chrome, or inside a reusable component that a third component
241
+ * nests.
242
+ *
243
+ * Miss them and an entry edit reports success while the pages that advertise
244
+ * it keep serving the old set for the whole revalidate window — with the rail
245
+ * on the home page, the single most visible place the change was supposed to
246
+ * appear.
247
+ *
248
+ * The first level is the only thing that differs from the form walk: a
249
+ * document uses a collection by BINDING a collection block to its slug. After
250
+ * that the fan-out is identical, so it is delegated rather than restated.
251
+ *
252
+ * The collection's OWN routes are deliberately not here. They are addresses,
253
+ * not screens — a fallback listing has no screen document at all, and a
254
+ * template screen serves the collection's paths rather than its own — so the
255
+ * caller derives them from the slug and sends them beside these.
256
+ *
257
+ * Pure, and separate from the Firestore read, so the closure is testable
258
+ * without a database. Cycle-safe by delegation: both walks it defers to bound
259
+ * themselves.
260
+ */ export function screenIdsUsingCollectionDeep(collectionSlug, sources) {
261
+ if (!collectionSlug) return [];
262
+ const screenIds = new Set();
263
+ for (const candidate of sources.screens){
264
+ if (!isLiveUsageCandidate(candidate)) continue;
265
+ if (nodesRenderCollection(candidate.nodes, collectionSlug)) {
266
+ screenIds.add(candidate.id);
267
+ }
268
+ }
269
+ for (const candidate of sources.layouts){
270
+ if (!isLiveUsageCandidate(candidate)) continue;
271
+ if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue;
272
+ // The layout itself renders no URL; the screens beneath it do.
273
+ for (const screenId of screenIdsUsingLayoutDeep(candidate.id, sources.screens, sources.layouts)){
274
+ screenIds.add(screenId);
275
+ }
276
+ }
277
+ for (const candidate of sources.components){
278
+ if (!isLiveUsageCandidate(candidate)) continue;
279
+ if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue;
280
+ // And a component renders wherever it is placed, however deeply nested.
281
+ for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)){
282
+ screenIds.add(screenId);
283
+ }
284
+ }
285
+ return [
286
+ ...screenIds
287
+ ];
288
+ }
229
289
  /**
230
290
  * One collection's documents, with published nodes attached when the scan
231
291
  * needs to search them.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.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 * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\nimport {\n getSystemEmailTemplate,\n SYSTEM_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/system-email-catalog'\nimport {\n getTenantEmail,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/tenant-email-catalog'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Screens only: `'email'` for a campaign email design (AGL-3287), which\n * a scan reports as an `emailDesign` rather than a page.\n */\n kind?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n /**\n * `emailTemplate` is one of the site's own transactional emails\n * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287).\n *\n * `emailDesign` is an email designed for a campaign: a `kind: 'email'`\n * screen document, `id` its screen id. It is its own type rather than a\n * `screen` because it is never a page. A caller that took it for one sent\n * the reader to the page settings — a slug, SEO and a Publish button that\n * would serve the email as a route — and a cache drop has no page to drop.\n *\n * `systemEmail` is one of the platform's own emails\n * (`systemEmailTemplates/{key}`, at the root, belonging to no site), `id`\n * its catalog key (AGL-3318). Only the platform marketing site's blocks\n * have one: that is the site those emails read their header and footer\n * from.\n */\n type:\n | 'screen'\n | 'layout'\n | 'component'\n | 'collection'\n | 'emailTemplate'\n | 'emailDesign'\n | 'systemEmail'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks, and a fourth for components. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n /**\n * The site's transactional email designs, each on its published version\n * (AGL-3287). Read only where a scan asks \"what places this component\" —\n * {@link scanComponentUsage} lists them — because an email is not a page:\n * nothing is cached for it, so no closure that decides which pages to drop\n * has any use for them, and none reads them.\n */\n emailTemplates?: UsageCandidate[]\n /**\n * The platform's own emails, each on its published version (AGL-3318).\n * Read only when the site scanned is the platform marketing site, whose\n * email blocks those emails place, and listed by {@link scanComponentUsage}\n * alone for the reason `emailTemplates` is: an email is not a page.\n */\n systemEmails?: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Every place the renderer expands an instance: published screen versions\n * (email designs among them — a campaign email is a screen), published layout\n * versions, OTHER component definitions — `composeReusableComponentNodes`\n * grafts nested instances, so a component used only inside another component\n * is genuinely used — and, when the caller read them, the site's transactional\n * emails, which graft a placed header or footer at send time (AGL-3287), and\n * the platform's own emails, which graft the platform marketing site's header\n * and footer (AGL-3318). Omitting any of them would report \"used nowhere\" for\n * something that renders and invite a confident deletion, which is worse than\n * showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type:\n type === 'screen' && candidate.kind === 'email'\n ? 'emailDesign'\n : type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n collect(sources.emailTemplates ?? [], 'emailTemplate')\n collect(sources.systemEmails ?? [], 'systemEmail')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (\n dependent.type === 'component' &&\n !seenComponents.has(dependent.id)\n ) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n // An email — transactional, designed for a campaign, or one of the\n // platform's own — is not a page and nothing caches it, so it\n // contributes no screen and is not followed (AGL-3287, AGL-3318). A\n // transactional or platform email's id is a catalog key, and walking\n // it as a component would read a key as a definition that does not\n // exist.\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n *\n * `emailTemplates` — the site's transactional email designs (AGL-3287) — has\n * the screens' shape: a parent holding the published `versionId`, the tree on\n * that version. The catalog is code and fixed, and a template document only\n * exists once somebody pressed Design, so most sites have none and the read\n * costs one empty query. Its documents carry no name, so each is labelled with\n * the catalog's.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n hostRef.collection(collectionName),\n collectionName,\n options,\n )\n}\n\n/**\n * The platform's own emails as a usage corpus (AGL-3318).\n *\n * They live at the ROOT, `systemEmailTemplates/{key}`, because they belong to\n * no site, with the shape a site's own emails have: the published `versionId`\n * on the template, the tree on that version, and no name but the catalog's.\n * Only a scan of the platform marketing site asks for them, since its email\n * blocks are the ones they place. Read under the same bound, and reporting\n * the same `truncated`, as a site's collections; the catalog is code, so a\n * document exists only for an email somebody designed, and the read is a\n * handful of documents.\n */\nexport async function readSystemEmailUsageCandidates(\n firestore: Pick<FirebaseFirestore.Firestore, 'collection'>,\n options: { limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n firestore.collection(SYSTEM_EMAIL_COLLECTION),\n SYSTEM_EMAIL_COLLECTION,\n { withNodes: true, limit: options.limit },\n )\n}\n\n/** The read both of the above make, over the collection each one names. */\nasync function readCandidatesIn(\n collection: FirebaseFirestore.CollectionReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION\n | typeof SYSTEM_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await collection.limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName:\n collectionName === TENANT_EMAIL_COLLECTION\n ? (getTenantEmail(docSnapshot.id)?.name ?? docSnapshot.id)\n : collectionName === SYSTEM_EMAIL_COLLECTION\n ? (getSystemEmailTemplate(docSnapshot.id)?.name ??\n docSnapshot.id)\n : docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Screens only: a campaign email design is a screen document that is\n // never a page (AGL-3287).\n ...(collectionName === 'screens' && docSnapshot.get('kind')\n ? { kind: String(docSnapshot.get('kind')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","decodeStoredNodes","getSystemEmailTemplate","SYSTEM_EMAIL_COLLECTION","getTenantEmail","TENANT_EMAIL_COLLECTION","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","kind","via","versionId","screens","layouts","components","emailTemplates","systemEmails","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","readUsageCandidates","hostRef","collectionName","options","readCandidatesIn","collection","readSystemEmailUsageCandidates","firestore","withNodes","limit","docs","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-usage.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 * Which live pages a change reaches, for a site's screens, layouts and\n * components.\n *\n * The corpus read and the two closures over it, held here rather than in the\n * console because the console is no longer the only side that needs them. A\n * dataset record is written from the tenant as well — a form submission, an\n * automation step — and those writes make the same pages stale as a console\n * edit does. Two implementations of \"which screens render this\" is the shape\n * that put AGL-1223 in the tree: one reader handled a node tree's second\n * storage form and the other did not, so half the corpus answered \"used\n * nowhere\" and a publish dropped no cache.\n *\n * Nothing here is dataset-specific, and nothing is console-specific. The\n * console's `scan-artifact-usage` re-exports these and keeps the parts that\n * are about the console's own \"what would I break\" copy.\n */\n\nimport {\n nodesReferenceComponent,\n nodesRenderCollection,\n decodeStoredNodes,\n type ReusableComponentProp,\n} from '@aglyn/aglyn/server'\nimport {\n getSystemEmailTemplate,\n SYSTEM_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/system-email-catalog'\nimport {\n getTenantEmail,\n TENANT_EMAIL_COLLECTION,\n} from '@aglyn/shared-util-email/tenant-email-catalog'\n\n/** A screen/layout/component reduced to what a usage scan needs. */\nexport interface UsageCandidate {\n id: string\n displayName?: string\n /** Legacy field some older documents used instead of `displayName`. */\n name?: string\n deletedAt?: unknown\n /**\n * Node tree to search. For screens and layouts this is the PUBLISHED\n * version's nodes (what visitors see); for components it is the definition\n * tree off the component document, which is what the runtime reads.\n */\n nodes?: Record<string, any> | null\n /** Published version, carried through so the caller can deep-link. */\n versionId?: string\n /** Screens only: the layout they render inside. */\n layoutId?: string\n /** Screens only: the screen they nest under, which is part of their path. */\n parentId?: string\n /**\n * Screens only: `'email'` for a campaign email design (AGL-3287), which\n * a scan reports as an `emailDesign` rather than a page.\n */\n kind?: string\n /**\n * Components only: the properties the definition declares (AGL-1247). A\n * Link property's default renders as a link wherever an instance leaves the\n * property unset, and it is stored here, not in `nodes` (AGL-2846).\n */\n props?: ReadonlyArray<ReusableComponentProp | null | undefined> | null\n}\n\n/** One document that depends on the artifact a scan was asked about. */\nexport interface UsageDependent {\n /**\n * `emailTemplate` is one of the site's own transactional emails\n * (`hosts/{h}/emailTemplates/{key}`), `id` its catalog key (AGL-3287).\n *\n * `emailDesign` is an email designed for a campaign: a `kind: 'email'`\n * screen document, `id` its screen id. It is its own type rather than a\n * `screen` because it is never a page. A caller that took it for one sent\n * the reader to the page settings — a slug, SEO and a Publish button that\n * would serve the email as a route — and a cache drop has no page to drop.\n *\n * `systemEmail` is one of the platform's own emails\n * (`systemEmailTemplates/{key}`, at the root, belonging to no site), `id`\n * its catalog key (AGL-3318). Only the platform marketing site's blocks\n * have one: that is the site those emails read their header and footer\n * from.\n */\n type:\n | 'screen'\n | 'layout'\n | 'component'\n | 'collection'\n | 'emailTemplate'\n | 'emailDesign'\n | 'systemEmail'\n id: string\n name: string\n via: Array<'id' | 'name'>\n versionId?: string\n /**\n * HOW the dependent references the artifact — screens and collection\n * listings (AGL-703, AGL-2806).\n *\n * A component or a layout has exactly one kind of dependent and the noun\n * says everything: an instance, or a binding. A screen has three, and they\n * break in three different ways — a link goes dead, a child moves, a\n * collection loses the page it renders through. Copy that could not tell\n * them apart would have to describe the worst case every time. A collection\n * listing's dependents are all links, and say so.\n */\n relation?: 'link' | 'child' | 'template'\n}\n\n/** The three corpora every closure below walks, and a fourth for components. */\nexport interface UsageSources {\n screens: UsageCandidate[]\n layouts: UsageCandidate[]\n components: UsageCandidate[]\n /**\n * The site's transactional email designs, each on its published version\n * (AGL-3287). Read only where a scan asks \"what places this component\" —\n * {@link scanComponentUsage} lists them — because an email is not a page:\n * nothing is cached for it, so no closure that decides which pages to drop\n * has any use for them, and none reads them.\n */\n emailTemplates?: UsageCandidate[]\n /**\n * The platform's own emails, each on its published version (AGL-3318).\n * Read only when the site scanned is the platform marketing site, whose\n * email blocks those emails place, and listed by {@link scanComponentUsage}\n * alone for the reason `emailTemplates` is: an email is not a page.\n */\n systemEmails?: UsageCandidate[]\n}\n\n/** `displayName`, falling back to a legacy `name`, then the raw id. */\nexport function usageCandidateLabel(candidate: UsageCandidate): string {\n return String(candidate.displayName ?? candidate.name ?? candidate.id)\n}\n\n/** A soft-deleted document renders nothing, so it depends on nothing. */\nexport const isLiveUsageCandidate = (candidate: UsageCandidate): boolean =>\n !candidate.deletedAt\n\n/**\n * Everything that references a reusable component (AGL-703).\n *\n * Every place the renderer expands an instance: published screen versions\n * (email designs among them — a campaign email is a screen), published layout\n * versions, OTHER component definitions — `composeReusableComponentNodes`\n * grafts nested instances, so a component used only inside another component\n * is genuinely used — and, when the caller read them, the site's transactional\n * emails, which graft a placed header or footer at send time (AGL-3287), and\n * the platform's own emails, which graft the platform marketing site's header\n * and footer (AGL-3318). Omitting any of them would report \"used nowhere\" for\n * something that renders and invite a confident deletion, which is worse than\n * showing nothing at all.\n */\nexport function scanComponentUsage(\n componentId: string,\n sources: UsageSources,\n): UsageDependent[] {\n if (!componentId) return []\n const dependents: UsageDependent[] = []\n const collect = (\n candidates: UsageCandidate[],\n type: UsageDependent['type'],\n ) => {\n for (const candidate of candidates) {\n if (!isLiveUsageCandidate(candidate)) continue\n // A component never counts as using itself, however it nests.\n if (type === 'component' && candidate.id === componentId) continue\n if (!nodesReferenceComponent(candidate.nodes, componentId)) continue\n dependents.push({\n type:\n type === 'screen' && candidate.kind === 'email'\n ? 'emailDesign'\n : type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n // Instances reference by id, so a rename can never break them.\n via: ['id'],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n })\n }\n }\n collect(sources.screens, 'screen')\n collect(sources.layouts, 'layout')\n collect(sources.components, 'component')\n collect(sources.emailTemplates ?? [], 'emailTemplate')\n collect(sources.systemEmails ?? [], 'systemEmail')\n return dependents\n}\n\n/**\n * Everything rendering inside a layout (AGL-703).\n *\n * Two kinds of dependent, both expressed by the same `layoutId` pointer:\n *\n * - **screens**, which name the layout they render inside;\n * - **other layouts**, since a layout can itself sit inside one. A nested\n * layout is a real dependent — deleting its parent unwraps every screen\n * underneath it — so leaving layouts out would report a parent layout as\n * used only by the screens that name it directly, and none of the ones\n * that reach it through a child.\n *\n * A layout never counts as its own dependent; `canNestLayout` refuses that,\n * and this refuses to report it even if stored data holds one.\n */\nexport function scanLayoutUsage(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): UsageDependent[] {\n if (!layoutId) return []\n const dependentsOf = (\n candidates: UsageCandidate[],\n type: 'screen' | 'layout',\n ) =>\n candidates\n .filter(\n (candidate) =>\n isLiveUsageCandidate(candidate) &&\n candidate.layoutId === layoutId &&\n candidate.id !== layoutId,\n )\n .map((candidate) => ({\n type,\n id: candidate.id,\n name: usageCandidateLabel(candidate),\n via: ['id' as const],\n ...(candidate.versionId ? { versionId: candidate.versionId } : {}),\n }))\n return [\n ...dependentsOf(screens, 'screen'),\n ...dependentsOf(layouts, 'layout'),\n ]\n}\n\n/**\n * Every live screen rendered inside `layoutId`, at ANY nesting depth\n * (AGL-1150).\n *\n * `scanLayoutUsage` answers one level. Layouts nest — a screen points at a\n * layout, which can point at a parent layout, and `compose-screen-nodes` walks\n * that whole chain when composing a page. So publishing a layout changes every\n * screen below it, not just the ones bound to it directly, and a cache drop\n * that only handles the direct level leaves the rest showing stale chrome for\n * the full revalidate window.\n *\n * Pure, and separate from the Firestore read, so the nesting behaviour is\n * testable without a database.\n *\n * Cycle-safe. `canNestLayout` refuses to create a cycle, but a document written\n * straight to Firestore is not bound by that, and a cycle here would hang a\n * publish request rather than surface anything.\n */\nexport function screenIdsUsingLayoutDeep(\n layoutId: string,\n screens: UsageCandidate[],\n layouts: UsageCandidate[] = [],\n): string[] {\n if (!layoutId) return []\n const screenIds = new Set<string>()\n const seenLayouts = new Set<string>([layoutId])\n let frontier = [layoutId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanLayoutUsage(id, screens, layouts)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (!seenLayouts.has(dependent.id)) {\n seenLayouts.add(dependent.id)\n next.push(dependent.id)\n }\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output contains `componentId`, however\n * indirectly (AGL-1161).\n *\n * `scanComponentUsage` answers one level and returns three kinds of dependent.\n * Only one of them is a screen, and the other two both reach screens by routes\n * a single-level scan cannot see:\n *\n * - a **component** dependent nests the target inside itself, and that outer\n * component may itself only be used inside a third — so component→component\n * edges have to be followed to a fixed point;\n * - a **layout** dependent puts the component in page chrome, which every\n * screen under that layout renders. Layouts nest, so that is\n * `screenIdsUsingLayoutDeep`, not a direct `layoutId` match.\n *\n * Miss either and a publish reports success while some pages keep serving the\n * old component for the full revalidate window — the failure this whole arc\n * exists to remove, and the one that is hardest to notice because the pages\n * that ARE dropped update instantly.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database — the same split `screenIdsUsingLayoutDeep` uses.\n *\n * Cycle-safe. `composeReusableComponentNodes` would not survive a cycle, but a\n * document written straight to Firestore is not bound by what the editor\n * allows, and a cycle here would hang a publish rather than surface anything.\n */\nexport function screenIdsUsingComponentDeep(\n componentId: string,\n sources: UsageSources,\n): string[] {\n if (!componentId) return []\n const screenIds = new Set<string>()\n const seenComponents = new Set<string>([componentId])\n // Layouts are resolved through their own deep walk, so remember which ones\n // have already been expanded: two components in the same layout would\n // otherwise re-walk the whole layout tree once each.\n const seenLayouts = new Set<string>()\n let frontier = [componentId]\n\n while (frontier.length) {\n const next: string[] = []\n for (const id of frontier) {\n for (const dependent of scanComponentUsage(id, sources)) {\n if (dependent.type === 'screen') {\n screenIds.add(dependent.id)\n } else if (dependent.type === 'layout') {\n if (seenLayouts.has(dependent.id)) continue\n seenLayouts.add(dependent.id)\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n dependent.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n } else if (\n dependent.type === 'component' &&\n !seenComponents.has(dependent.id)\n ) {\n seenComponents.add(dependent.id)\n next.push(dependent.id)\n }\n // An email — transactional, designed for a campaign, or one of the\n // platform's own — is not a page and nothing caches it, so it\n // contributes no screen and is not followed (AGL-3287, AGL-3318). A\n // transactional or platform email's id is a catalog key, and walking\n // it as a component would read a key as a definition that does not\n // exist.\n }\n }\n frontier = next\n }\n\n return [...screenIds]\n}\n\n/**\n * Every live screen whose rendered output renders the collection\n * `collectionSlug`, however indirectly.\n *\n * The content twin of the console's `screenIdsUsingFormDeep`, and the same\n * walk for the same reason. Saving, publishing or deleting an ENTRY changes\n * every page that renders the collection it belongs to, and only some of those\n * pages are derivable from the collection's slug. `/blog`, `/blog/my-post` and\n * `/blog/category/guides` are; the \"Latest posts\" rail on the home page, the\n * category pills in a layout's chrome and the search box in a header are not\n * — they are found by searching node trees, and they can sit on a screen,\n * inside page chrome, or inside a reusable component that a third component\n * nests.\n *\n * Miss them and an entry edit reports success while the pages that advertise\n * it keep serving the old set for the whole revalidate window — with the rail\n * on the home page, the single most visible place the change was supposed to\n * appear.\n *\n * The first level is the only thing that differs from the form walk: a\n * document uses a collection by BINDING a collection block to its slug. After\n * that the fan-out is identical, so it is delegated rather than restated.\n *\n * The collection's OWN routes are deliberately not here. They are addresses,\n * not screens — a fallback listing has no screen document at all, and a\n * template screen serves the collection's paths rather than its own — so the\n * caller derives them from the slug and sends them beside these.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database. Cycle-safe by delegation: both walks it defers to bound\n * themselves.\n */\nexport function screenIdsUsingCollectionDeep(\n collectionSlug: string,\n sources: UsageSources,\n): string[] {\n if (!collectionSlug) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRenderCollection(candidate.nodes, collectionSlug)) {\n screenIds.add(candidate.id)\n }\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue\n // The layout itself renders no URL; the screens beneath it do.\n for (const screenId of screenIdsUsingLayoutDeep(\n candidate.id,\n sources.screens,\n sources.layouts,\n )) {\n screenIds.add(screenId)\n }\n }\n for (const candidate of sources.components) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRenderCollection(candidate.nodes, collectionSlug)) continue\n // And a component renders wherever it is placed, however deeply nested.\n for (const screenId of screenIdsUsingComponentDeep(candidate.id, sources)) {\n screenIds.add(screenId)\n }\n }\n\n return [...screenIds]\n}\n\nexport interface UsageCandidateRead {\n candidates: UsageCandidate[]\n /**\n * The collection held more documents than `limit` allowed, so the scan below\n * it is INCOMPLETE.\n *\n * Returned rather than logged, because the two callers owe the user\n * different things: an advisory \"what would I break\" can show a partial\n * answer and say so, while a cache drop that silently scans a prefix reports\n * a successful publish and leaves real pages stale.\n */\n truncated: boolean\n}\n\n/**\n * One collection's documents, with published nodes attached when the scan\n * needs to search them.\n *\n * `limit` is a real bound, not a guess: it is fetched with one extra document\n * so exceeding it is DETECTED rather than assumed away. A caller that ignores\n * `truncated` is choosing to be wrong quietly.\n *\n * `emailTemplates` — the site's transactional email designs (AGL-3287) — has\n * the screens' shape: a parent holding the published `versionId`, the tree on\n * that version. The catalog is code and fixed, and a template document only\n * exists once somebody pressed Design, so most sites have none and the read\n * costs one empty query. Its documents carry no name, so each is labelled with\n * the catalog's.\n */\nexport async function readUsageCandidates(\n hostRef: FirebaseFirestore.DocumentReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n hostRef.collection(collectionName),\n collectionName,\n options,\n )\n}\n\n/**\n * The platform's own emails as a usage corpus (AGL-3318).\n *\n * They live at the ROOT, `systemEmailTemplates/{key}`, because they belong to\n * no site, with the shape a site's own emails have: the published `versionId`\n * on the template, the tree on that version, and no name but the catalog's.\n * Only a scan of the platform marketing site asks for them, since its email\n * blocks are the ones they place. Read under the same bound, and reporting\n * the same `truncated`, as a site's collections; the catalog is code, so a\n * document exists only for an email somebody designed, and the read is a\n * handful of documents.\n */\nexport async function readSystemEmailUsageCandidates(\n firestore: Pick<FirebaseFirestore.Firestore, 'collection'>,\n options: { limit: number },\n): Promise<UsageCandidateRead> {\n return readCandidatesIn(\n firestore.collection(SYSTEM_EMAIL_COLLECTION),\n SYSTEM_EMAIL_COLLECTION,\n { withNodes: true, limit: options.limit },\n )\n}\n\n/** The read both of the above make, over the collection each one names. */\nasync function readCandidatesIn(\n collection: FirebaseFirestore.CollectionReference,\n collectionName:\n | 'screens'\n | 'layouts'\n | 'components'\n | typeof TENANT_EMAIL_COLLECTION\n | typeof SYSTEM_EMAIL_COLLECTION,\n options: { withNodes: boolean; limit: number },\n): Promise<UsageCandidateRead> {\n const { withNodes, limit } = options\n // One over the limit: if the extra document comes back, there was more than\n // we are about to look at. Cheaper than a count() and exact.\n const docs = await collection.limit(limit + 1).get()\n const truncated = docs.size > limit\n const inScope = truncated ? docs.docs.slice(0, limit) : docs.docs\n\n const candidates = await Promise.all(\n inScope.map(async (docSnapshot) => {\n const versionId = docSnapshot.get('versionId')\n // Components keep their tree on the document; screens and layouts keep\n // it on the published version.\n //\n // BOTH reads are decoded (AGL-1223). A component document is msgpack\n // for anything promoted since components were compressed and a plain\n // map for everything older, and `decodeStoredNodes` returns a map\n // unchanged — so the branch below cannot be simplified back into a raw\n // read on either side.\n //\n // What rides on this: every consumer walks the value with\n // `Object.values`, which over a `Buffer` yields byte NUMBERS and\n // matches nothing. The answer that produces is \"used nowhere\" — for\n // `/api/hosts/where-used` an invitation to delete something a live page\n // renders, and for `/api/screens/revalidate` a publish that drops no\n // cache and leaves the old page served.\n const nodes =\n collectionName === 'components'\n ? decodeStoredNodes(docSnapshot.get('nodes'))\n : withNodes && versionId\n ? await docSnapshot.ref\n .collection('versions')\n .doc(String(versionId))\n .get()\n .then((version) => decodeStoredNodes(version.get('nodes')))\n .catch(() => null)\n : null\n return {\n id: docSnapshot.id,\n displayName:\n collectionName === TENANT_EMAIL_COLLECTION\n ? (getTenantEmail(docSnapshot.id)?.name ?? docSnapshot.id)\n : collectionName === SYSTEM_EMAIL_COLLECTION\n ? (getSystemEmailTemplate(docSnapshot.id)?.name ??\n docSnapshot.id)\n : docSnapshot.get('displayName'),\n name: docSnapshot.get('name'),\n deletedAt: docSnapshot.get('deletedAt'),\n nodes,\n ...(versionId ? { versionId: String(versionId) } : {}),\n ...(docSnapshot.get('layoutId')\n ? { layoutId: String(docSnapshot.get('layoutId')) }\n : {}),\n // Screens only, and only the screen scan reads it: a child screen is\n // a dependent of its parent, because its PATH is built from it\n // (AGL-703).\n ...(docSnapshot.get('parentId')\n ? { parentId: String(docSnapshot.get('parentId')) }\n : {}),\n // Screens only: a campaign email design is a screen document that is\n // never a page (AGL-3287).\n ...(collectionName === 'screens' && docSnapshot.get('kind')\n ? { kind: String(docSnapshot.get('kind')) }\n : {}),\n // Components only: the declared properties, whose Link defaults render\n // as links wherever an instance leaves one unset (AGL-2846). The same\n // document the tree came from, so it costs no read.\n ...(collectionName === 'components' &&\n Array.isArray(docSnapshot.get('props'))\n ? { props: docSnapshot.get('props') }\n : {}),\n } satisfies UsageCandidate\n }),\n )\n\n return { candidates, truncated }\n}\n\n/**\n * Every screen, layout and component of a site, with their node trees — the\n * corpus every tree-searching closure above walks.\n *\n * Each collection ONCE, in memory: a query per level would multiply round\n * trips by the nesting depth of the graph. Shared so two scans cannot read\n * different corpora under different bounds, and a change to one cannot quietly\n * narrow the other.\n */\nexport async function readUsageSources(\n hostRef: FirebaseFirestore.DocumentReference,\n limit: number,\n): Promise<{ candidates: UsageSources; truncated: boolean }> {\n const [screens, layouts, components] = await Promise.all([\n readUsageCandidates(hostRef, 'screens', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'layouts', { withNodes: true, limit }),\n readUsageCandidates(hostRef, 'components', { withNodes: true, limit }),\n ])\n return {\n candidates: {\n screens: screens.candidates,\n layouts: layouts.candidates,\n components: components.candidates,\n },\n truncated: screens.truncated || layouts.truncated || components.truncated,\n }\n}\n"],"names":["nodesReferenceComponent","nodesRenderCollection","decodeStoredNodes","getSystemEmailTemplate","SYSTEM_EMAIL_COLLECTION","getTenantEmail","TENANT_EMAIL_COLLECTION","usageCandidateLabel","candidate","String","displayName","name","id","isLiveUsageCandidate","deletedAt","scanComponentUsage","componentId","sources","dependents","collect","candidates","type","nodes","push","kind","via","versionId","screens","layouts","components","emailTemplates","systemEmails","scanLayoutUsage","layoutId","dependentsOf","filter","map","screenIdsUsingLayoutDeep","screenIds","Set","seenLayouts","frontier","length","next","dependent","add","has","screenIdsUsingComponentDeep","seenComponents","screenId","screenIdsUsingCollectionDeep","collectionSlug","readUsageCandidates","hostRef","collectionName","options","readCandidatesIn","collection","readSystemEmailUsageCandidates","firestore","withNodes","limit","docs","get","truncated","size","inScope","slice","Promise","all","docSnapshot","ref","doc","then","version","catch","parentId","Array","isArray","props","readUsageSources"],"mappings":"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"}