@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
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 +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -32,7 +32,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
32
32
|
*
|
|
33
33
|
* A record changed one dataset, so this drops the pages that repeat over that
|
|
34
34
|
* dataset and nothing else. The set is derived from the SAME binding the
|
|
35
|
-
* published page expands — `
|
|
35
|
+
* published page expands — `repeatKeys`, the generic
|
|
36
36
|
* `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a
|
|
37
37
|
* list of components that may repeat. A component allowlist was already wrong
|
|
38
38
|
* the day repeat stopped being the Stack's: a heading bound to a dataset
|
|
@@ -57,7 +57,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
57
57
|
* failed drop is reported to the caller and logged, never thrown: a record
|
|
58
58
|
* must not be lost because a cache refused, and the TTL is still underneath as
|
|
59
59
|
* the backstop it was always meant to be.
|
|
60
|
-
*/ import { ORG_SCOPE_TOKEN, hostIdsFromScope,
|
|
60
|
+
*/ import { ORG_SCOPE_TOKEN, hostIdsFromScope, repeatKeys, screenRoutePathToUrl } from "@aglyn/aglyn/server";
|
|
61
61
|
import { readUsageSources, screenIdsUsingComponentDeep, screenIdsUsingLayoutDeep, isLiveUsageCandidate } from "./live-page-usage.js";
|
|
62
62
|
/**
|
|
63
63
|
* How many documents per collection the per-site scan reads.
|
|
@@ -111,12 +111,12 @@ function throttleAllows(key, now) {
|
|
|
111
111
|
/**
|
|
112
112
|
* Whether a node tree repeats over any of `keys`.
|
|
113
113
|
*
|
|
114
|
-
* Asks `
|
|
114
|
+
* Asks `repeatKeys` — literally the function the tenant composer calls
|
|
115
115
|
* to decide which datasets to load for a page — so this cannot come to
|
|
116
116
|
* disagree with what actually renders. A predicate even slightly narrower than
|
|
117
117
|
* the expansion's is a page that renders rows and never refreshes them.
|
|
118
118
|
*/ function nodesRepeatDataset(nodes, keys) {
|
|
119
|
-
return
|
|
119
|
+
return repeatKeys(nodes).some((key)=>keys.has(key));
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
122
|
* Every live screen whose rendered output repeats over the dataset, however
|
|
@@ -162,8 +162,8 @@ function throttleAllows(key, now) {
|
|
|
162
162
|
* The keys a page can be bound to this dataset by: its id, and the display
|
|
163
163
|
* name editors type into the Repeat attribute.
|
|
164
164
|
*
|
|
165
|
-
* Both, because the expansion looks a key up either way (
|
|
166
|
-
*
|
|
165
|
+
* Both, because the expansion looks a key up either way (the data plugin's
|
|
166
|
+
* repeat rows resolve an id first and then a display name). Matching only the id would
|
|
167
167
|
* miss every binding written by name, which is the older and still-supported
|
|
168
168
|
* half of them.
|
|
169
169
|
*/ function datasetKeysOf(snapshot) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dataset-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 * A dataset record write refreshes the live pages that show it (AGL-3113).\n *\n * ## What was missing\n *\n * Every other change a visitor can see announces itself the moment it lands: a\n * publish, a layout, a component, a content entry, a redirect rule, the\n * maintenance toggle. A dataset RECORD did not. It was written — by a form, by\n * an automation, over `/v1`, from the console — and the pages repeating over\n * it went on serving the rows they were built from until the hour-long window\n * lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,\n * which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:\n * a backstop doing a job nothing else was doing.\n *\n * ## Scope, not the site\n *\n * A record changed one dataset, so this drops the pages that repeat over that\n * dataset and nothing else. The set is derived from the SAME binding the\n * published page expands — `repeatDatasetKeys`, the generic\n * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a\n * list of components that may repeat. A component allowlist was already wrong\n * the day repeat stopped being the Stack's: a heading bound to a dataset\n * renders rows exactly as a Stack does, and would have been missed.\n *\n * Three corpora, because the composer reads repeat keys after the layout chain\n * and the reusable-component graft: a repeat in a layout's chrome or inside a\n * component reaches every screen that renders it.\n *\n * ## Which sites\n *\n * Datasets are ORG-scoped and shared with sites through `visibleTo` — every\n * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its\n * own copy of the rows under its own `tenant-data:{hostId}` tag and its own\n * page keys, so one record write is a fan-out. A missing `visibleTo` is\n * visible to NOBODY and announces nothing; defaulting it to org-wide here\n * would re-make AGL-1466 in the cache layer.\n *\n * ## Best effort, always\n *\n * The record is already written by the time any of this runs. A refused or\n * failed drop is reported to the caller and logged, never thrown: a record\n * must not be lost because a cache refused, and the TTL is still underneath as\n * the backstop it was always meant to be.\n */\n\nimport {\n ORG_SCOPE_TOKEN,\n hostIdsFromScope,\n repeatDatasetKeys,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the per-site scan reads.\n *\n * The console's publish scan uses the same number for the same reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan would\n * report a refreshed site and leave real pages serving the old rows. Bounded\n * all the same, and when the bound bites it is SAID rather than absorbed.\n */\nexport const DATASET_SCAN_LIMIT = 2000\n\n/**\n * How many sites one record write fans out to.\n *\n * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide\n * dataset can reach this — an org with more sites than this pays a scan per\n * site on every write otherwise, which is a cost the person writing one row\n * did not ask for. Sites past the cap keep the TTL they had before this\n * existed, and the shortfall is returned and logged rather than swallowed.\n */\nexport const MAX_DATASET_FANOUT_HOSTS = 30\n\n/**\n * How long one site-and-dataset pair waits before announcing again.\n *\n * A 1,000-row import is one announce because the importer announces after its\n * loop, not per row. This covers the other burst — many small writes arriving\n * separately, a form under load or an automation fanning out — where each one\n * would otherwise pay a full scan per site and hand the tenant the same path\n * list again.\n *\n * Leading edge, so the FIRST write of a burst refreshes immediately; what is\n * skipped is a repeat inside the window, and the next write after it announces\n * again. The cost of the skip is bounded by the window: a row written during\n * one is visible no later than the next announce.\n */\nexport const DATASET_ANNOUNCE_WINDOW_MS = 2000\n\n/** Last announce per `orgId/datasetId/hostId`, for the window above. */\nconst lastAnnouncedAt = new Map<string, number>()\n\n/**\n * Bounded so a long-lived server cannot grow this map without limit. Far above\n * the number of datasets any one instance writes in a window; when it bites,\n * the oldest entries simply lose their throttle and announce again.\n */\nconst MAX_THROTTLE_ENTRIES = 5000\n\nfunction throttleAllows(key: string, now: number): boolean {\n const previous = lastAnnouncedAt.get(key)\n if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {\n return false\n }\n if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear()\n lastAnnouncedAt.set(key, now)\n return true\n}\n\n/** Test seam: the window is process-wide state, so a spec has to reset it. */\nexport function resetDatasetAnnounceThrottle(): void {\n lastAnnouncedAt.clear()\n}\n\n/** One site's share of a dataset change. */\nexport interface DatasetLivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\nexport interface DatasetLivePageScope {\n targets: DatasetLivePageTarget[]\n /**\n * Sites the fan-out cap left out. Zero in the ordinary case, and reported\n * rather than logged because a caller that says \"refreshed\" while some sites\n * were skipped is the failure this whole arc exists to remove.\n */\n hostsDropped: number\n /**\n * Why there is nothing to announce, or `'ok'`.\n *\n * `'not-rendered'` is a success: a dataset no page repeats over has no live\n * page to refresh. It is kept apart from `'error'` for the same reason the\n * revalidate route keeps `not-routed` apart from a refusal — one means there\n * was nothing to do, the other means something was left stale.\n */\n reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error'\n}\n\n/**\n * Whether a node tree repeats over any of `keys`.\n *\n * Asks `repeatDatasetKeys` — literally the function the tenant composer calls\n * to decide which datasets to load for a page — so this cannot come to\n * disagree with what actually renders. A predicate even slightly narrower than\n * the expansion's is a page that renders rows and never refreshes them.\n */\nfunction nodesRepeatDataset(\n nodes: UsageCandidate['nodes'],\n keys: ReadonlySet<string>,\n): boolean {\n return repeatDatasetKeys(nodes).some((key) => keys.has(key))\n}\n\n/**\n * Every live screen whose rendered output repeats over the dataset, however\n * indirectly.\n *\n * The same three-bucket closure a placed form's pages are found by, for the\n * same reason: a binding is found by SEARCHING node trees rather than by\n * matching a pointer, so it can sit on the screen, in the chrome of the layout\n * the screen renders inside, or inside a reusable component a third component\n * nests. After the first level the fan-out is identical, so it is delegated\n * rather than restated.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database.\n */\nexport function screenIdsRepeatingDataset(\n keys: ReadonlySet<string>,\n sources: UsageSources,\n): string[] {\n if (!keys.size) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id)\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) 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 (!nodesRepeatDataset(candidate.nodes, keys)) 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\n/**\n * The keys a page can be bound to this dataset by: its id, and the display\n * name editors type into the Repeat attribute.\n *\n * Both, because the expansion looks a key up either way (`getDatasets`\n * resolves an id first and then a display name). Matching only the id would\n * miss every binding written by name, which is the older and still-supported\n * half of them.\n */\nfunction datasetKeysOf(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Set<string> {\n const keys = new Set<string>([snapshot.id])\n const displayName = String(snapshot.get('displayName') ?? '').trim()\n if (displayName) keys.add(displayName)\n return keys\n}\n\n/**\n * The sites a dataset is shared with, as host ids.\n *\n * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and\n * both enforcement layers underneath it: a document nobody scoped is shared\n * with nobody, and reading it as org-wide here would announce one org's\n * dataset change to sites that cannot see the dataset at all.\n */\nasync function hostIdsSharingDataset(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | undefined,\n): Promise<{ hostIds: string[]; hostsDropped: number }> {\n if (!Array.isArray(visibleTo) || !visibleTo.length) {\n return { hostIds: [], hostsDropped: 0 }\n }\n if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {\n const named = hostIdsFromScope(visibleTo)\n return { hostIds: named, hostsDropped: 0 }\n }\n // One over the cap, so a shortfall is detected rather than assumed away.\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(MAX_DATASET_FANOUT_HOSTS + 1)\n .get()\n const all = hosts.docs.map((doc) => doc.id)\n return {\n hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),\n hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS),\n }\n}\n\n/** One site's paths, or `null` when the site has nothing to refresh. */\nasync function targetForHost(\n firestore: Firestore,\n hostId: string,\n keys: ReadonlySet<string>,\n): Promise<DatasetLivePageTarget | null> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n // Without a subdomain there is no tenant deployment holding cached pages for\n // this site — the ordinary state of one not yet given a name, not a failure.\n if (!subdomain) return null\n\n const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = screenIdsRepeatingDataset(keys, sources.candidates)\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: sources.truncated,\n }\n}\n\n/**\n * Every live page a change to this dataset's records makes stale, per site.\n *\n * Reads only; announcing is the caller's, because the two runtimes announce\n * differently — the console posts to the tenant over the shared secret, and\n * the tenant drops its own caches in process. Splitting it here is what lets\n * both use one answer to the question of WHICH pages.\n *\n * Never throws. A scope that could not be computed is a scope of nothing, and\n * the TTL underneath is unaffected.\n */\nexport async function datasetLivePageScope(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n}): Promise<DatasetLivePageScope> {\n const { firestore, orgId, datasetId } = options\n if (!orgId || !datasetId) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n try {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('datasets')\n .doc(datasetId)\n .get()\n if (!snapshot.exists) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n const { hostIds, hostsDropped } = await hostIdsSharingDataset(\n firestore,\n orgId,\n snapshot.get('visibleTo') as readonly string[] | undefined,\n )\n if (!hostIds.length) {\n return { targets: [], hostsDropped, reason: 'no-sites' }\n }\n const keys = datasetKeysOf(snapshot)\n const resolved = await Promise.all(\n hostIds.map(async (hostId) => {\n try {\n return await targetForHost(firestore, hostId, keys)\n } catch (error) {\n // One unreadable site must not cost the others their refresh.\n console.error('[dataset-live-pages] site scan failed', hostId, error)\n return null\n }\n }),\n )\n const targets = resolved.filter(\n (target): target is DatasetLivePageTarget => target !== null,\n )\n return {\n targets,\n hostsDropped,\n reason: targets.length ? 'ok' : 'not-rendered',\n }\n } catch (error) {\n console.error('[dataset-live-pages] scope failed', datasetId, error)\n return { targets: [], hostsDropped: 0, reason: 'error' }\n }\n}\n\n/**\n * How one runtime drops a site's cached pages.\n *\n * The generic seam, registered by the deployment that owns the caches. The\n * tenant registers an in-process drop at boot; the console announces over the\n * shared secret and passes its own dropper at the call site. Core names\n * neither, exactly as it names no repeat source.\n *\n * Returns whether the drop landed. It must never throw: everything above it\n * has already been written.\n */\nexport type LivePageDropper = (\n target: DatasetLivePageTarget,\n) => Promise<boolean>\n\nlet registeredDropper: LivePageDropper | undefined\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n registeredDropper = drop\n return () => {\n if (registeredDropper === drop) registeredDropper = undefined\n }\n}\n\nexport interface DatasetAnnounceResult {\n /** Sites whose cached pages were dropped. */\n sitesRefreshed: number\n /** Pages named across those sites. */\n pathsRefreshed: number\n /** Sites the drop was attempted on and refused. */\n sitesRefused: number\n /** Sites past the fan-out cap, and sites whose scan was truncated. */\n hostsDropped: number\n scanTruncated: boolean\n /** `'throttled'` when a drop for this pair already went out just now. */\n reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper'\n}\n\nconst EMPTY_RESULT: Omit<DatasetAnnounceResult, 'reason'> = {\n sitesRefreshed: 0,\n pathsRefreshed: 0,\n sitesRefused: 0,\n hostsDropped: 0,\n scanTruncated: false,\n}\n\n/**\n * Refresh every live page that shows this dataset's records.\n *\n * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and\n * never rejects, so a caller can `await` it inside the try that already guards\n * the write without any chance of turning a stored record into a failed one.\n *\n * `drop` overrides the registered dropper for callers that hold their own —\n * the console, whose announce is an authenticated request rather than a local\n * cache call.\n */\nexport async function announceDatasetRecordChange(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n drop?: LivePageDropper\n}): Promise<DatasetAnnounceResult> {\n const { firestore, orgId, datasetId } = options\n const drop = options.drop ?? registeredDropper\n if (!drop) {\n // Said out loud: a process that writes records and registered no dropper\n // is one where this feature is silently absent, which reads exactly like\n // a working one until somebody watches a page fail to change.\n console.warn(\n JSON.stringify({\n tag: 'AGL-3113:no-live-page-dropper',\n orgId,\n datasetId,\n }),\n )\n return { ...EMPTY_RESULT, reason: 'no-dropper' }\n }\n const throttleKey = `${orgId}/${datasetId}`\n if (!throttleAllows(throttleKey, Date.now())) {\n return { ...EMPTY_RESULT, reason: 'throttled' }\n }\n const scope = await datasetLivePageScope({ firestore, orgId, datasetId })\n if (!scope.targets.length) {\n return {\n ...EMPTY_RESULT,\n hostsDropped: scope.hostsDropped,\n reason: scope.reason,\n }\n }\n\n let sitesRefreshed = 0\n let sitesRefused = 0\n let pathsRefreshed = 0\n for (const target of scope.targets) {\n let landed = false\n try {\n landed = await drop(target)\n } catch (error) {\n // A dropper that throws is a dropper that failed; it is not a write\n // that failed.\n console.error('[dataset-live-pages] drop failed', target.hostId, error)\n }\n if (landed) {\n sitesRefreshed += 1\n pathsRefreshed += target.paths.length\n } else {\n sitesRefused += 1\n }\n }\n\n const scanTruncated = scope.targets.some((target) => target.truncated)\n const result: DatasetAnnounceResult = {\n sitesRefreshed,\n pathsRefreshed,\n sitesRefused,\n hostsDropped: scope.hostsDropped,\n scanTruncated,\n reason: scope.reason,\n }\n // ONE line per announce, on success as well as failure, for the reason\n // `postTenantRevalidate` gives: a record that only logs its failures cannot\n // tell \"every write refreshed\" apart from \"the call never happened\", and\n // that ambiguity is how an eleven-day propagation outage went unnoticed.\n console.log(\n JSON.stringify({\n tag: 'AGL-3113:dataset-announce',\n orgId,\n datasetId,\n ...result,\n }),\n )\n return result\n}\n\n/**\n * One sentence for a refresh that did not cover every page, or `null` when it\n * did.\n *\n * Beside the call rather than at each surface, so the console cannot word this\n * differently in two places — the same reason `describeRevalidateShortfall`\n * lives beside `revalidateLivePages`. `lead` names the act that succeeded,\n * because only that word differs between surfaces: a record is SAVED, and\n * telling someone who fixed a typo that they published it is a worse error\n * than the staleness the sentence is about.\n */\nexport function describeDatasetAnnounceShortfall(\n result: DatasetAnnounceResult | null,\n lead = 'Saved.',\n): string | null {\n if (!result) return null\n // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a\n // dataset no page shows has no stale page, and a throttled drop means one\n // already went out a moment ago. Saying anything would be alarming and wrong.\n if (result.reason === 'error' || result.sitesRefused) {\n return (\n `${lead} The live pages could not be refreshed just now, so they ` +\n 'may show the previous records for up to an hour.'\n )\n }\n if (result.hostsDropped || result.scanTruncated) {\n return (\n `${lead} Some pages that use this dataset are too many to refresh at ` +\n 'once — they update on their own within an hour.'\n )\n }\n return null\n}\n\nexport default announceDatasetRecordChange\n"],"names":["ORG_SCOPE_TOKEN","hostIdsFromScope","repeatDatasetKeys","screenRoutePathToUrl","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","isLiveUsageCandidate","DATASET_SCAN_LIMIT","MAX_DATASET_FANOUT_HOSTS","DATASET_ANNOUNCE_WINDOW_MS","lastAnnouncedAt","Map","MAX_THROTTLE_ENTRIES","throttleAllows","key","now","previous","get","undefined","size","clear","set","resetDatasetAnnounceThrottle","nodesRepeatDataset","nodes","keys","some","has","screenIdsRepeatingDataset","sources","screenIds","Set","candidate","screens","add","id","layouts","screenId","components","datasetKeysOf","snapshot","displayName","String","trim","hostIdsSharingDataset","firestore","orgId","visibleTo","Array","isArray","length","hostIds","hostsDropped","includes","named","hosts","collection","where","limit","all","docs","map","doc","slice","Math","max","targetForHost","hostId","hostSnapshot","hostRef","exists","subdomain","paths","candidates","filter","path","Boolean","index","indexOf","cname","truncated","datasetLivePageScope","options","datasetId","targets","reason","resolved","Promise","error","console","target","registeredDropper","registerLivePageDropper","drop","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/dataset-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 * A dataset record write refreshes the live pages that show it (AGL-3113).\n *\n * ## What was missing\n *\n * Every other change a visitor can see announces itself the moment it lands: a\n * publish, a layout, a component, a content entry, a redirect rule, the\n * maintenance toggle. A dataset RECORD did not. It was written — by a form, by\n * an automation, over `/v1`, from the console — and the pages repeating over\n * it went on serving the rows they were built from until the hour-long window\n * lapsed. `PUBLISHED_SITE_DATA_TTL_SECONDS` was the propagation mechanism,\n * which is the same mistake AGL-1302 and AGL-1150 each had to undo elsewhere:\n * a backstop doing a job nothing else was doing.\n *\n * ## Scope, not the site\n *\n * A record changed one dataset, so this drops the pages that repeat over that\n * dataset and nothing else. The set is derived from the SAME binding the\n * published page expands — `repeatKeys`, the generic\n * `props.repeatDataset` any node can now carry (AGL-3111) — rather than from a\n * list of components that may repeat. A component allowlist was already wrong\n * the day repeat stopped being the Stack's: a heading bound to a dataset\n * renders rows exactly as a Stack does, and would have been missed.\n *\n * Three corpora, because the composer reads repeat keys after the layout chain\n * and the reusable-component graft: a repeat in a layout's chrome or inside a\n * component reaches every screen that renders it.\n *\n * ## Which sites\n *\n * Datasets are ORG-scoped and shared with sites through `visibleTo` — every\n * site in the org, or up to `MAX_SCOPE_HOSTS` named ones. Each site caches its\n * own copy of the rows under its own `tenant-data:{hostId}` tag and its own\n * page keys, so one record write is a fan-out. A missing `visibleTo` is\n * visible to NOBODY and announces nothing; defaulting it to org-wide here\n * would re-make AGL-1466 in the cache layer.\n *\n * ## Best effort, always\n *\n * The record is already written by the time any of this runs. A refused or\n * failed drop is reported to the caller and logged, never thrown: a record\n * must not be lost because a cache refused, and the TTL is still underneath as\n * the backstop it was always meant to be.\n */\n\nimport {\n ORG_SCOPE_TOKEN,\n hostIdsFromScope,\n repeatKeys,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport {\n readUsageSources,\n screenIdsUsingComponentDeep,\n screenIdsUsingLayoutDeep,\n isLiveUsageCandidate,\n type UsageCandidate,\n type UsageSources,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the per-site scan reads.\n *\n * The console's publish scan uses the same number for the same reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan would\n * report a refreshed site and leave real pages serving the old rows. Bounded\n * all the same, and when the bound bites it is SAID rather than absorbed.\n */\nexport const DATASET_SCAN_LIMIT = 2000\n\n/**\n * How many sites one record write fans out to.\n *\n * `MAX_SCOPE_HOSTS` is the ceiling on a NAMED selection, so only an org-wide\n * dataset can reach this — an org with more sites than this pays a scan per\n * site on every write otherwise, which is a cost the person writing one row\n * did not ask for. Sites past the cap keep the TTL they had before this\n * existed, and the shortfall is returned and logged rather than swallowed.\n */\nexport const MAX_DATASET_FANOUT_HOSTS = 30\n\n/**\n * How long one site-and-dataset pair waits before announcing again.\n *\n * A 1,000-row import is one announce because the importer announces after its\n * loop, not per row. This covers the other burst — many small writes arriving\n * separately, a form under load or an automation fanning out — where each one\n * would otherwise pay a full scan per site and hand the tenant the same path\n * list again.\n *\n * Leading edge, so the FIRST write of a burst refreshes immediately; what is\n * skipped is a repeat inside the window, and the next write after it announces\n * again. The cost of the skip is bounded by the window: a row written during\n * one is visible no later than the next announce.\n */\nexport const DATASET_ANNOUNCE_WINDOW_MS = 2000\n\n/** Last announce per `orgId/datasetId/hostId`, for the window above. */\nconst lastAnnouncedAt = new Map<string, number>()\n\n/**\n * Bounded so a long-lived server cannot grow this map without limit. Far above\n * the number of datasets any one instance writes in a window; when it bites,\n * the oldest entries simply lose their throttle and announce again.\n */\nconst MAX_THROTTLE_ENTRIES = 5000\n\nfunction throttleAllows(key: string, now: number): boolean {\n const previous = lastAnnouncedAt.get(key)\n if (previous !== undefined && now - previous < DATASET_ANNOUNCE_WINDOW_MS) {\n return false\n }\n if (lastAnnouncedAt.size >= MAX_THROTTLE_ENTRIES) lastAnnouncedAt.clear()\n lastAnnouncedAt.set(key, now)\n return true\n}\n\n/** Test seam: the window is process-wide state, so a spec has to reset it. */\nexport function resetDatasetAnnounceThrottle(): void {\n lastAnnouncedAt.clear()\n}\n\n/** One site's share of a dataset change. */\nexport interface DatasetLivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) that repeat over the dataset. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\nexport interface DatasetLivePageScope {\n targets: DatasetLivePageTarget[]\n /**\n * Sites the fan-out cap left out. Zero in the ordinary case, and reported\n * rather than logged because a caller that says \"refreshed\" while some sites\n * were skipped is the failure this whole arc exists to remove.\n */\n hostsDropped: number\n /**\n * Why there is nothing to announce, or `'ok'`.\n *\n * `'not-rendered'` is a success: a dataset no page repeats over has no live\n * page to refresh. It is kept apart from `'error'` for the same reason the\n * revalidate route keeps `not-routed` apart from a refusal — one means there\n * was nothing to do, the other means something was left stale.\n */\n reason: 'ok' | 'no-dataset' | 'no-sites' | 'not-rendered' | 'error'\n}\n\n/**\n * Whether a node tree repeats over any of `keys`.\n *\n * Asks `repeatKeys` — literally the function the tenant composer calls\n * to decide which datasets to load for a page — so this cannot come to\n * disagree with what actually renders. A predicate even slightly narrower than\n * the expansion's is a page that renders rows and never refreshes them.\n */\nfunction nodesRepeatDataset(\n nodes: UsageCandidate['nodes'],\n keys: ReadonlySet<string>,\n): boolean {\n return repeatKeys(nodes).some((key) => keys.has(key))\n}\n\n/**\n * Every live screen whose rendered output repeats over the dataset, however\n * indirectly.\n *\n * The same three-bucket closure a placed form's pages are found by, for the\n * same reason: a binding is found by SEARCHING node trees rather than by\n * matching a pointer, so it can sit on the screen, in the chrome of the layout\n * the screen renders inside, or inside a reusable component a third component\n * nests. After the first level the fan-out is identical, so it is delegated\n * rather than restated.\n *\n * Pure, and separate from the Firestore read, so the closure is testable\n * without a database.\n */\nexport function screenIdsRepeatingDataset(\n keys: ReadonlySet<string>,\n sources: UsageSources,\n): string[] {\n if (!keys.size) return []\n const screenIds = new Set<string>()\n\n for (const candidate of sources.screens) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (nodesRepeatDataset(candidate.nodes, keys)) screenIds.add(candidate.id)\n }\n for (const candidate of sources.layouts) {\n if (!isLiveUsageCandidate(candidate)) continue\n if (!nodesRepeatDataset(candidate.nodes, keys)) 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 (!nodesRepeatDataset(candidate.nodes, keys)) 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\n/**\n * The keys a page can be bound to this dataset by: its id, and the display\n * name editors type into the Repeat attribute.\n *\n * Both, because the expansion looks a key up either way (the data plugin's\n * repeat rows resolve an id first and then a display name). Matching only the id would\n * miss every binding written by name, which is the older and still-supported\n * half of them.\n */\nfunction datasetKeysOf(\n snapshot: FirebaseFirestore.DocumentSnapshot,\n): Set<string> {\n const keys = new Set<string>([snapshot.id])\n const displayName = String(snapshot.get('displayName') ?? '').trim()\n if (displayName) keys.add(displayName)\n return keys\n}\n\n/**\n * The sites a dataset is shared with, as host ids.\n *\n * Fails CLOSED on a missing or empty `visibleTo`, matching `visibleToHost` and\n * both enforcement layers underneath it: a document nobody scoped is shared\n * with nobody, and reading it as org-wide here would announce one org's\n * dataset change to sites that cannot see the dataset at all.\n */\nasync function hostIdsSharingDataset(\n firestore: Firestore,\n orgId: string,\n visibleTo: readonly string[] | undefined,\n): Promise<{ hostIds: string[]; hostsDropped: number }> {\n if (!Array.isArray(visibleTo) || !visibleTo.length) {\n return { hostIds: [], hostsDropped: 0 }\n }\n if (!visibleTo.includes(ORG_SCOPE_TOKEN)) {\n const named = hostIdsFromScope(visibleTo)\n return { hostIds: named, hostsDropped: 0 }\n }\n // One over the cap, so a shortfall is detected rather than assumed away.\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(MAX_DATASET_FANOUT_HOSTS + 1)\n .get()\n const all = hosts.docs.map((doc) => doc.id)\n return {\n hostIds: all.slice(0, MAX_DATASET_FANOUT_HOSTS),\n hostsDropped: Math.max(0, all.length - MAX_DATASET_FANOUT_HOSTS),\n }\n}\n\n/** One site's paths, or `null` when the site has nothing to refresh. */\nasync function targetForHost(\n firestore: Firestore,\n hostId: string,\n keys: ReadonlySet<string>,\n): Promise<DatasetLivePageTarget | null> {\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n // Without a subdomain there is no tenant deployment holding cached pages for\n // this site — the ordinary state of one not yet given a name, not a failure.\n if (!subdomain) return null\n\n const sources = await readUsageSources(hostRef, DATASET_SCAN_LIMIT)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n const paths = screenIdsRepeatingDataset(keys, sources.candidates)\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: sources.truncated,\n }\n}\n\n/**\n * Every live page a change to this dataset's records makes stale, per site.\n *\n * Reads only; announcing is the caller's, because the two runtimes announce\n * differently — the console posts to the tenant over the shared secret, and\n * the tenant drops its own caches in process. Splitting it here is what lets\n * both use one answer to the question of WHICH pages.\n *\n * Never throws. A scope that could not be computed is a scope of nothing, and\n * the TTL underneath is unaffected.\n */\nexport async function datasetLivePageScope(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n}): Promise<DatasetLivePageScope> {\n const { firestore, orgId, datasetId } = options\n if (!orgId || !datasetId) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n try {\n const snapshot = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('datasets')\n .doc(datasetId)\n .get()\n if (!snapshot.exists) {\n return { targets: [], hostsDropped: 0, reason: 'no-dataset' }\n }\n const { hostIds, hostsDropped } = await hostIdsSharingDataset(\n firestore,\n orgId,\n snapshot.get('visibleTo') as readonly string[] | undefined,\n )\n if (!hostIds.length) {\n return { targets: [], hostsDropped, reason: 'no-sites' }\n }\n const keys = datasetKeysOf(snapshot)\n const resolved = await Promise.all(\n hostIds.map(async (hostId) => {\n try {\n return await targetForHost(firestore, hostId, keys)\n } catch (error) {\n // One unreadable site must not cost the others their refresh.\n console.error('[dataset-live-pages] site scan failed', hostId, error)\n return null\n }\n }),\n )\n const targets = resolved.filter(\n (target): target is DatasetLivePageTarget => target !== null,\n )\n return {\n targets,\n hostsDropped,\n reason: targets.length ? 'ok' : 'not-rendered',\n }\n } catch (error) {\n console.error('[dataset-live-pages] scope failed', datasetId, error)\n return { targets: [], hostsDropped: 0, reason: 'error' }\n }\n}\n\n/**\n * How one runtime drops a site's cached pages.\n *\n * The generic seam, registered by the deployment that owns the caches. The\n * tenant registers an in-process drop at boot; the console announces over the\n * shared secret and passes its own dropper at the call site. Core names\n * neither, exactly as it names no repeat source.\n *\n * Returns whether the drop landed. It must never throw: everything above it\n * has already been written.\n */\nexport type LivePageDropper = (\n target: DatasetLivePageTarget,\n) => Promise<boolean>\n\nlet registeredDropper: LivePageDropper | undefined\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n registeredDropper = drop\n return () => {\n if (registeredDropper === drop) registeredDropper = undefined\n }\n}\n\nexport interface DatasetAnnounceResult {\n /** Sites whose cached pages were dropped. */\n sitesRefreshed: number\n /** Pages named across those sites. */\n pathsRefreshed: number\n /** Sites the drop was attempted on and refused. */\n sitesRefused: number\n /** Sites past the fan-out cap, and sites whose scan was truncated. */\n hostsDropped: number\n scanTruncated: boolean\n /** `'throttled'` when a drop for this pair already went out just now. */\n reason: DatasetLivePageScope['reason'] | 'throttled' | 'no-dropper'\n}\n\nconst EMPTY_RESULT: Omit<DatasetAnnounceResult, 'reason'> = {\n sitesRefreshed: 0,\n pathsRefreshed: 0,\n sitesRefused: 0,\n hostsDropped: 0,\n scanTruncated: false,\n}\n\n/**\n * Refresh every live page that shows this dataset's records.\n *\n * The one call a write path makes. BEST EFFORT, ALWAYS: it never throws and\n * never rejects, so a caller can `await` it inside the try that already guards\n * the write without any chance of turning a stored record into a failed one.\n *\n * `drop` overrides the registered dropper for callers that hold their own —\n * the console, whose announce is an authenticated request rather than a local\n * cache call.\n */\nexport async function announceDatasetRecordChange(options: {\n firestore: Firestore\n orgId: string\n datasetId: string\n drop?: LivePageDropper\n}): Promise<DatasetAnnounceResult> {\n const { firestore, orgId, datasetId } = options\n const drop = options.drop ?? registeredDropper\n if (!drop) {\n // Said out loud: a process that writes records and registered no dropper\n // is one where this feature is silently absent, which reads exactly like\n // a working one until somebody watches a page fail to change.\n console.warn(\n JSON.stringify({\n tag: 'AGL-3113:no-live-page-dropper',\n orgId,\n datasetId,\n }),\n )\n return { ...EMPTY_RESULT, reason: 'no-dropper' }\n }\n const throttleKey = `${orgId}/${datasetId}`\n if (!throttleAllows(throttleKey, Date.now())) {\n return { ...EMPTY_RESULT, reason: 'throttled' }\n }\n const scope = await datasetLivePageScope({ firestore, orgId, datasetId })\n if (!scope.targets.length) {\n return {\n ...EMPTY_RESULT,\n hostsDropped: scope.hostsDropped,\n reason: scope.reason,\n }\n }\n\n let sitesRefreshed = 0\n let sitesRefused = 0\n let pathsRefreshed = 0\n for (const target of scope.targets) {\n let landed = false\n try {\n landed = await drop(target)\n } catch (error) {\n // A dropper that throws is a dropper that failed; it is not a write\n // that failed.\n console.error('[dataset-live-pages] drop failed', target.hostId, error)\n }\n if (landed) {\n sitesRefreshed += 1\n pathsRefreshed += target.paths.length\n } else {\n sitesRefused += 1\n }\n }\n\n const scanTruncated = scope.targets.some((target) => target.truncated)\n const result: DatasetAnnounceResult = {\n sitesRefreshed,\n pathsRefreshed,\n sitesRefused,\n hostsDropped: scope.hostsDropped,\n scanTruncated,\n reason: scope.reason,\n }\n // ONE line per announce, on success as well as failure, for the reason\n // `postTenantRevalidate` gives: a record that only logs its failures cannot\n // tell \"every write refreshed\" apart from \"the call never happened\", and\n // that ambiguity is how an eleven-day propagation outage went unnoticed.\n console.log(\n JSON.stringify({\n tag: 'AGL-3113:dataset-announce',\n orgId,\n datasetId,\n ...result,\n }),\n )\n return result\n}\n\n/**\n * One sentence for a refresh that did not cover every page, or `null` when it\n * did.\n *\n * Beside the call rather than at each surface, so the console cannot word this\n * differently in two places — the same reason `describeRevalidateShortfall`\n * lives beside `revalidateLivePages`. `lead` names the act that succeeded,\n * because only that word differs between surfaces: a record is SAVED, and\n * telling someone who fixed a typo that they published it is a worse error\n * than the staleness the sentence is about.\n */\nexport function describeDatasetAnnounceShortfall(\n result: DatasetAnnounceResult | null,\n lead = 'Saved.',\n): string | null {\n if (!result) return null\n // `not-rendered`, `no-sites` and `throttled` are deliberately silent: a\n // dataset no page shows has no stale page, and a throttled drop means one\n // already went out a moment ago. Saying anything would be alarming and wrong.\n if (result.reason === 'error' || result.sitesRefused) {\n return (\n `${lead} The live pages could not be refreshed just now, so they ` +\n 'may show the previous records for up to an hour.'\n )\n }\n if (result.hostsDropped || result.scanTruncated) {\n return (\n `${lead} Some pages that use this dataset are too many to refresh at ` +\n 'once — they update on their own within an hour.'\n )\n }\n return null\n}\n\nexport default announceDatasetRecordChange\n"],"names":["ORG_SCOPE_TOKEN","hostIdsFromScope","repeatKeys","screenRoutePathToUrl","readUsageSources","screenIdsUsingComponentDeep","screenIdsUsingLayoutDeep","isLiveUsageCandidate","DATASET_SCAN_LIMIT","MAX_DATASET_FANOUT_HOSTS","DATASET_ANNOUNCE_WINDOW_MS","lastAnnouncedAt","Map","MAX_THROTTLE_ENTRIES","throttleAllows","key","now","previous","get","undefined","size","clear","set","resetDatasetAnnounceThrottle","nodesRepeatDataset","nodes","keys","some","has","screenIdsRepeatingDataset","sources","screenIds","Set","candidate","screens","add","id","layouts","screenId","components","datasetKeysOf","snapshot","displayName","String","trim","hostIdsSharingDataset","firestore","orgId","visibleTo","Array","isArray","length","hostIds","hostsDropped","includes","named","hosts","collection","where","limit","all","docs","map","doc","slice","Math","max","targetForHost","hostId","hostSnapshot","hostRef","exists","subdomain","paths","candidates","filter","path","Boolean","index","indexOf","cname","truncated","datasetLivePageScope","options","datasetId","targets","reason","resolved","Promise","error","console","target","registeredDropper","registerLivePageDropper","drop","EMPTY_RESULT","sitesRefreshed","pathsRefreshed","sitesRefused","scanTruncated","announceDatasetRecordChange","warn","JSON","stringify","tag","throttleKey","Date","scope","landed","result","log","describeDatasetAnnounceShortfall","lead"],"mappings":"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"}
|
|
@@ -14,7 +14,32 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { type OrgEntitlements, type OrgFeatureFlags } from '@aglyn/aglyn/server';
|
|
17
18
|
import { type DuplicableHostResourceKind } from '@aglyn/aglyn/app-utils/duplicate-resource';
|
|
19
|
+
/** How the copy of each kind is made. */
|
|
20
|
+
interface KindRecipe {
|
|
21
|
+
collection: string;
|
|
22
|
+
/** The field the name lives in — `displayName` on core's kinds; a declared kind names its own. */
|
|
23
|
+
nameField: string;
|
|
24
|
+
/** The source fields carried onto the copy; everything else stays behind. */
|
|
25
|
+
fields: readonly string[];
|
|
26
|
+
/** The back-pointer a copied version carries, when the kind versions at all. */
|
|
27
|
+
versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId';
|
|
28
|
+
/** The plan counter the copy is charged to. */
|
|
29
|
+
quotaKey?: keyof OrgEntitlements & string;
|
|
30
|
+
/** The feature the plan must include for the kind to exist at all. */
|
|
31
|
+
entitlement?: keyof OrgFeatureFlags;
|
|
32
|
+
/** Whether a `slug` on the source becomes a unique `-copy` slug. */
|
|
33
|
+
slug?: boolean;
|
|
34
|
+
/** What the copy carries beyond the source's fields. */
|
|
35
|
+
stamps?: Readonly<Record<string, unknown>>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The recipe for a kind: core's own, else the one its plugin declared, else
|
|
39
|
+
* `null` — which is refused, never guessed at. A declared copy is met against
|
|
40
|
+
* the same plan counter and feature as the kind's create.
|
|
41
|
+
*/
|
|
42
|
+
export declare function recipeFor(kind: string): KindRecipe | null;
|
|
18
43
|
export interface DuplicateResourceOptions {
|
|
19
44
|
orgId: string;
|
|
20
45
|
hostId: string;
|
|
@@ -53,3 +78,4 @@ export type DuplicateResourceResult = {
|
|
|
53
78
|
* may write content on `hostId`; this decides everything after that.
|
|
54
79
|
*/
|
|
55
80
|
export declare function duplicateResource(kind: DuplicableHostResourceKind, options: DuplicateResourceOptions): Promise<DuplicateResourceResult>;
|
|
81
|
+
export {};
|
|
@@ -18,9 +18,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
* Duplicating a site resource whole, as a draft (AGL-2936).
|
|
19
19
|
*
|
|
20
20
|
* One module for every kind that lives under a site — screens and email
|
|
21
|
-
* designs, reusable components, layouts, templates
|
|
22
|
-
* so the console route, the AI
|
|
23
|
-
* later door make the same copy:
|
|
21
|
+
* designs, reusable components, layouts, templates and forms, and the kinds
|
|
22
|
+
* a plugin declares a copy for (a workflow) — so the console route, the AI
|
|
23
|
+
* runtime's `duplicate_resource` tool and any later door make the same copy:
|
|
24
|
+
* the same fields, the same uniqueness
|
|
24
25
|
* rules, the same band arithmetic, the same activity rows. A caller that
|
|
25
26
|
* has verified who is asking and that they may write the site hands over
|
|
26
27
|
* the ids and gets back the copy's id, or a refusal it can answer with.
|
|
@@ -35,8 +36,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
35
36
|
* points at it, and nothing points at the copy;
|
|
36
37
|
* - a layout, component or form is served only where a page places it,
|
|
37
38
|
* and nothing places the copy;
|
|
38
|
-
* - a
|
|
39
|
-
* is cleared;
|
|
39
|
+
* - a declared kind drops what its plugin says a copy must not carry —
|
|
40
|
+
* a workflow's trigger is cleared, so the copy runs nothing;
|
|
40
41
|
* - a template is inert until it is used.
|
|
41
42
|
*
|
|
42
43
|
* COUNTED LIKE A CREATE. The copy is one more document in a collection the
|
|
@@ -69,6 +70,7 @@ import { artifactCreateListKeys } from "@aglyn/aglyn/app-utils/artifact-list-key
|
|
|
69
70
|
import { billableScreenIds, checkEntitlement, checkQuota, claimAttempt, createResourceUid, decodeStoredNodes, encodeStoredNodes, newFormListFields, NON_PAGE_SCREEN_MAX_PER_HOST, nonPageScreenIds, SCREEN_KIND_EMAIL, SCREEN_KIND_TEMPLATE, screenClaimsToBeAPage } from "@aglyn/aglyn/server";
|
|
70
71
|
import { DUPLICABLE_RESOURCE_NOUNS, DUPLICATE_BUSY_MESSAGE, duplicateDisplayName, duplicateVersionNote, uniqueDuplicateName, uniqueDuplicateSlug } from "@aglyn/aglyn/app-utils/duplicate-resource";
|
|
71
72
|
import { formDesignReboundTo } from "@aglyn/aglyn/app-utils/forms";
|
|
73
|
+
import { pluginHostResource } from "@aglyn/aglyn/plugin-manager/plugin-host-resources";
|
|
72
74
|
import { Timestamp } from "firebase-admin/firestore";
|
|
73
75
|
import { logResourceDuplicated } from "./duplicate-activity.js";
|
|
74
76
|
import firebaseAdmin from "./firebase-admin.js";
|
|
@@ -76,9 +78,14 @@ import firebaseAdmin from "./firebase-admin.js";
|
|
|
76
78
|
* Per kind, the same fields the create route's allow-list admits, less the
|
|
77
79
|
* ones a copy must not inherit: a screen's `versionId` (the copy mints its
|
|
78
80
|
* own), a template's `source` (stamped `authored` below — a copy of a
|
|
79
|
-
* marketplace template is the customer's own),
|
|
80
|
-
* (cleared, so the copy runs nothing until armed), and anything the server
|
|
81
|
+
* marketplace template is the customer's own), and anything the server
|
|
81
82
|
* stamps on every create.
|
|
83
|
+
*
|
|
84
|
+
* The platform's own kinds only. A kind a plugin owns — a workflow, whose
|
|
85
|
+
* copy clears its trigger so it runs nothing until armed — is declared by
|
|
86
|
+
* that plugin beside its collection in `plugins.config.json` and read by
|
|
87
|
+
* {@link recipeFor} (AGL-3080), compiled so a copy never depends on this
|
|
88
|
+
* process having loaded the plugin.
|
|
82
89
|
*/ const RECIPES = {
|
|
83
90
|
screen: {
|
|
84
91
|
collection: 'screens',
|
|
@@ -175,29 +182,44 @@ import firebaseAdmin from "./firebase-admin.js";
|
|
|
175
182
|
quotaKey: 'formsPerHost',
|
|
176
183
|
entitlement: 'reusableComponents',
|
|
177
184
|
slug: true
|
|
178
|
-
},
|
|
179
|
-
workflow: {
|
|
180
|
-
collection: 'workflows',
|
|
181
|
-
nameField: 'name',
|
|
182
|
-
fields: [
|
|
183
|
-
'steps',
|
|
184
|
-
'returnValue'
|
|
185
|
-
],
|
|
186
|
-
quotaKey: 'workflowsPerHost',
|
|
187
|
-
entitlement: 'workflows',
|
|
188
|
-
stamps: {
|
|
189
|
-
trigger: null
|
|
190
|
-
}
|
|
191
185
|
}
|
|
192
186
|
};
|
|
187
|
+
/**
|
|
188
|
+
* The recipe for a kind: core's own, else the one its plugin declared, else
|
|
189
|
+
* `null` — which is refused, never guessed at. A declared copy is met against
|
|
190
|
+
* the same plan counter and feature as the kind's create.
|
|
191
|
+
*/ export function recipeFor(kind) {
|
|
192
|
+
if (Object.prototype.hasOwnProperty.call(RECIPES, kind)) {
|
|
193
|
+
var _RECIPES_kind;
|
|
194
|
+
return (_RECIPES_kind = RECIPES[kind]) != null ? _RECIPES_kind : null;
|
|
195
|
+
}
|
|
196
|
+
const declared = pluginHostResource(kind);
|
|
197
|
+
if (!(declared == null ? void 0 : declared.duplicate)) return null;
|
|
198
|
+
return _extends({
|
|
199
|
+
collection: declared.collection,
|
|
200
|
+
nameField: declared.duplicate.nameField,
|
|
201
|
+
fields: declared.duplicate.fields
|
|
202
|
+
}, declared.quotaKey ? {
|
|
203
|
+
quotaKey: declared.quotaKey
|
|
204
|
+
} : {}, declared.entitlement ? {
|
|
205
|
+
entitlement: declared.entitlement
|
|
206
|
+
} : {}, declared.duplicate.stamps ? {
|
|
207
|
+
stamps: declared.duplicate.stamps
|
|
208
|
+
} : {});
|
|
209
|
+
}
|
|
193
210
|
const asRecord = (value)=>value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
194
211
|
/**
|
|
195
212
|
* Makes the copy. The caller has authenticated `uid` and verified that they
|
|
196
213
|
* may write content on `hostId`; this decides everything after that.
|
|
197
214
|
*/ export async function duplicateResource(kind, options) {
|
|
198
215
|
var _options_sourceId, _options_attemptKey;
|
|
199
|
-
const recipe =
|
|
216
|
+
const recipe = recipeFor(kind);
|
|
200
217
|
const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
|
|
218
|
+
if (!recipe) return {
|
|
219
|
+
ok: false,
|
|
220
|
+
status: 400,
|
|
221
|
+
error: 'That resource cannot be duplicated'
|
|
222
|
+
};
|
|
201
223
|
const sourceId = String((_options_sourceId = options.sourceId) != null ? _options_sourceId : '').trim().slice(0, 64);
|
|
202
224
|
if (!sourceId) return {
|
|
203
225
|
ok: false,
|
|
@@ -325,14 +347,14 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
325
347
|
return {
|
|
326
348
|
ok: false,
|
|
327
349
|
status: 400,
|
|
328
|
-
error: 'That
|
|
350
|
+
error: 'That page is not an email design'
|
|
329
351
|
};
|
|
330
352
|
}
|
|
331
353
|
if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {
|
|
332
354
|
return {
|
|
333
355
|
ok: false,
|
|
334
356
|
status: 400,
|
|
335
|
-
error: 'Only a page or a collection entry template can be duplicated as a
|
|
357
|
+
error: 'Only a page or a collection entry template can be duplicated as a page'
|
|
336
358
|
};
|
|
337
359
|
}
|
|
338
360
|
}
|
|
@@ -366,7 +388,7 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
366
388
|
return {
|
|
367
389
|
ok: false,
|
|
368
390
|
status: 403,
|
|
369
|
-
error: 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template
|
|
391
|
+
error: 'This site is at its limit of ' + `${NON_PAGE_SCREEN_MAX_PER_HOST} email designs and template pages — ` + 'delete some to make room'
|
|
370
392
|
};
|
|
371
393
|
}
|
|
372
394
|
} else if (recipe.quotaKey) {
|
|
@@ -376,7 +398,7 @@ async function copy(firestore, kind, recipe, options) {
|
|
|
376
398
|
return {
|
|
377
399
|
ok: false,
|
|
378
400
|
status: 403,
|
|
379
|
-
error: `Your plan includes ${quota.limit} ${noun}s — ` + 'upgrade in Billing for more'
|
|
401
|
+
error: `Your plan includes ${quota.limit} ${noun}${quota.limit === 1 ? '' : 's'} — ` + 'upgrade in Billing for more'
|
|
380
402
|
};
|
|
381
403
|
}
|
|
382
404
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates, forms and workflows —\n * so the console route, the AI runtime's `duplicate_resource` tool and any\n * later door make the same copy: the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a workflow runs only when its trigger fires, and the copy's trigger\n * is cleared;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\n// The leaf module, not the server barrel: specs stage that barrel as a\n// closed world, and the list keys every create stamps (AGL-3321) are\n// nothing they have reason to name.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n newFormListFields,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `name` on a workflow, `displayName` elsewhere. */\n nameField: 'displayName' | 'name'\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Record<string, unknown>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), a workflow's `trigger`\n * (cleared, so the copy runs nothing until armed), and anything the server\n * stamps on every create.\n */\nconst RECIPES: Record<DuplicableHostResourceKind, KindRecipe> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n workflow: {\n collection: 'workflows',\n nameField: 'name',\n fields: ['steps', 'returnValue'],\n quotaKey: 'workflowsPerHost',\n entitlement: 'workflows',\n stamps: { trigger: null },\n },\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = RECIPES[kind]\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That screen is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a screen',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email and template screens — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}s — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n // The keys the copy's list finds it by (AGL-3321), from the copy's own\n // name and stamps — see `artifactCreateListKeys`.\n ...artifactCreateListKeys(recipe.collection, { ...doc, ...(recipe.stamps ?? {}) }),\n // A copied form starts as a new one does, with its own search keys and\n // nothing counted (AGL-3330); only the routing it copied is kept.\n ...(kind === 'form'\n ? newFormListFields({\n id,\n displayName: name,\n slug: doc['slug'],\n routing: doc['routing'] as Parameters<typeof newFormListFields>[0]['routing'],\n })\n : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["artifactCreateListKeys","billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","newFormListFields","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","workflow","trigger","asRecord","value","Array","isArray","duplicateResource","kind","options","recipe","noun","sourceId","String","trim","slice","ok","status","error","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","displayName","routing","createdAt","now","updatedAt","createdBy","versionNodes","nodes"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/duplicate-resource.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 * Duplicating a site resource whole, as a draft (AGL-2936).\n *\n * One module for every kind that lives under a site — screens and email\n * designs, reusable components, layouts, templates and forms, and the kinds\n * a plugin declares a copy for (a workflow) — so the console route, the AI\n * runtime's `duplicate_resource` tool and any later door make the same copy:\n * the same fields, the same uniqueness\n * rules, the same band arithmetic, the same activity rows. A caller that\n * has verified who is asking and that they may write the site hands over\n * the ids and gets back the copy's id, or a refusal it can answer with.\n *\n * WHAT MAKES THE COPY A DRAFT is different per kind, and each is the\n * omission of exactly one thing the create paths would otherwise do:\n *\n * - a screen is reachable only through the host's routing map, so the\n * copy gets no entry there — it has a slug of its own (`-copy`) but no\n * address until somebody publishes it;\n * - a collection's entry template renders only for the collection that\n * points at it, and nothing points at the copy;\n * - a layout, component or form is served only where a page places it,\n * and nothing places the copy;\n * - a declared kind drops what its plugin says a copy must not carry —\n * a workflow's trigger is cleared, so the copy runs nothing;\n * - a template is inert until it is used.\n *\n * COUNTED LIKE A CREATE. The copy is one more document in a collection the\n * plan bands, so it meets the same counter the create route meets — for a\n * screen that is not a page, the flat platform ceiling rather than a band —\n * and meets it INSIDE the transaction that writes the copy (AGL-2231): a count\n * taken before the write is a count N concurrent copies all pass. The\n * siblings are read once and answer three questions — the count, the name\n * the copy may take, and the slug — because three reads of one collection\n * is the reason a route and its readout come to disagree.\n *\n * THE VERSION TRAVELS WITH THE DOCUMENT. A besigner resource is opened by\n * its version, so the copy of a screen with no version is a screen nobody\n * can open. The source's most recently saved version is copied into the\n * copy's own `versions` as its first — no `versioning` entitlement is\n * owed for a first version — with its stored node bytes carried across\n * untouched: they are msgpack at rest, and re-encoding them is the\n * double-encode `encodeStoredNodes` exists to avoid.\n *\n * THE ONE TREE THAT IS REWRITTEN is a form's (AGL-3024). A form's design\n * names its own form inside itself, so carrying those bytes across hands the\n * copy a design that files every submission under the SOURCE — silently,\n * because the copy still renders and the row still lands. That binding is\n * moved onto the copy below. No other kind embeds its own id in its design,\n * so no other kind is decoded.\n */\n\n// The leaf module, not the server barrel: specs stage that barrel as a\n// closed world, and the list keys every create stamps (AGL-3321) are\n// nothing they have reason to name.\nimport { artifactCreateListKeys } from '@aglyn/aglyn/app-utils/artifact-list-keys'\nimport {\n billableScreenIds,\n checkEntitlement,\n checkQuota,\n claimAttempt,\n createResourceUid,\n decodeStoredNodes,\n encodeStoredNodes,\n newFormListFields,\n NON_PAGE_SCREEN_MAX_PER_HOST,\n nonPageScreenIds,\n SCREEN_KIND_EMAIL,\n SCREEN_KIND_TEMPLATE,\n screenClaimsToBeAPage,\n type BillableScreenSource,\n type OrgEntitlements,\n type OrgFeatureFlags,\n} from '@aglyn/aglyn/server'\nimport {\n DUPLICABLE_RESOURCE_NOUNS,\n DUPLICATE_BUSY_MESSAGE,\n duplicateDisplayName,\n duplicateVersionNote,\n uniqueDuplicateName,\n uniqueDuplicateSlug,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { formDesignReboundTo } from '@aglyn/aglyn/app-utils/forms'\nimport { pluginHostResource } from '@aglyn/aglyn/plugin-manager/plugin-host-resources'\nimport { Timestamp } from 'firebase-admin/firestore'\nimport { logResourceDuplicated } from './duplicate-activity'\nimport firebaseAdmin from './firebase-admin'\n\n/** How the copy of each kind is made. */\ninterface KindRecipe {\n collection: string\n /** The field the name lives in — `displayName` on core's kinds; a declared kind names its own. */\n nameField: string\n /** The source fields carried onto the copy; everything else stays behind. */\n fields: readonly string[]\n /** The back-pointer a copied version carries, when the kind versions at all. */\n versionParentField?: 'screenId' | 'layoutId' | 'componentId' | 'formId'\n /** The plan counter the copy is charged to. */\n quotaKey?: keyof OrgEntitlements & string\n /** The feature the plan must include for the kind to exist at all. */\n entitlement?: keyof OrgFeatureFlags\n /** Whether a `slug` on the source becomes a unique `-copy` slug. */\n slug?: boolean\n /** What the copy carries beyond the source's fields. */\n stamps?: Readonly<Record<string, unknown>>\n}\n\n/**\n * Per kind, the same fields the create route's allow-list admits, less the\n * ones a copy must not inherit: a screen's `versionId` (the copy mints its\n * own), a template's `source` (stamped `authored` below — a copy of a\n * marketplace template is the customer's own), and anything the server\n * stamps on every create.\n *\n * The platform's own kinds only. A kind a plugin owns — a workflow, whose\n * copy clears its trigger so it runs nothing until armed — is declared by\n * that plugin beside its collection in `plugins.config.json` and read by\n * {@link recipeFor} (AGL-3080), compiled so a copy never depends on this\n * process having loaded the plugin.\n */\nconst RECIPES: Partial<Record<DuplicableHostResourceKind, KindRecipe>> = {\n screen: {\n collection: 'screens',\n nameField: 'displayName',\n fields: [\n 'description',\n 'seo',\n 'kind',\n 'visibility',\n 'locale',\n 'protection',\n // The shared-layout binding. A version inherits it from its screen, so\n // a copy without it renders with no header or footer (AGL-3120).\n 'layoutId',\n ],\n versionParentField: 'screenId',\n quotaKey: 'screensPerHost',\n slug: true,\n },\n // No `quotaKey`: an email design is not a page, so it meets the flat\n // platform ceiling on non-page screens below rather than the page band.\n emailDesign: {\n collection: 'screens',\n nameField: 'displayName',\n fields: ['description', 'kind'],\n versionParentField: 'screenId',\n },\n component: {\n collection: 'components',\n nameField: 'displayName',\n // `kind` keeps an email block an email block (AGL-3287): without it the\n // copy reads as a page component, leaves every email editor's palette,\n // and turns up in page editors where its email blocks cannot render.\n fields: ['description', 'icon', 'rootId', 'nodes', 'props', 'kind'],\n versionParentField: 'componentId',\n entitlement: 'reusableComponents',\n },\n layout: {\n collection: 'layouts',\n nameField: 'displayName',\n fields: ['description'],\n versionParentField: 'layoutId',\n quotaKey: 'sharedLayoutsPerHost',\n },\n template: {\n collection: 'templates',\n nameField: 'displayName',\n fields: [\n 'kind',\n // A component template's own kind — email block or page component —\n // so Use template on a copy still makes the right one (AGL-3287).\n 'componentKind',\n 'description',\n 'category',\n 'placeholders',\n 'nodes',\n 'rootId',\n 'props',\n 'slug',\n 'seo',\n 'theme',\n ],\n quotaKey: 'templatesPerHost',\n stamps: { source: { type: 'authored' } },\n },\n form: {\n collection: 'forms',\n nameField: 'displayName',\n fields: [\n 'fields',\n 'consentFieldName',\n 'routing',\n 'legacyMatch',\n 'rootId',\n 'nodes',\n ],\n versionParentField: 'formId',\n quotaKey: 'formsPerHost',\n entitlement: 'reusableComponents',\n slug: true,\n },\n}\n\n/**\n * The recipe for a kind: core's own, else the one its plugin declared, else\n * `null` — which is refused, never guessed at. A declared copy is met against\n * the same plan counter and feature as the kind's create.\n */\nexport function recipeFor(kind: string): KindRecipe | null {\n if (Object.prototype.hasOwnProperty.call(RECIPES, kind)) {\n return RECIPES[kind as DuplicableHostResourceKind] ?? null\n }\n const declared = pluginHostResource(kind)\n if (!declared?.duplicate) return null\n return {\n collection: declared.collection,\n nameField: declared.duplicate.nameField,\n fields: declared.duplicate.fields,\n ...(declared.quotaKey\n ? { quotaKey: declared.quotaKey as keyof OrgEntitlements & string }\n : {}),\n ...(declared.entitlement\n ? { entitlement: declared.entitlement as keyof OrgFeatureFlags }\n : {}),\n ...(declared.duplicate.stamps ? { stamps: declared.duplicate.stamps } : {}),\n }\n}\n\nexport interface DuplicateResourceOptions {\n orgId: string\n hostId: string\n sourceId: string\n /** The name the copy takes; blank means `Copy of <source>`. */\n name?: string | null\n /** The verified person making the copy. */\n uid: string\n email?: string | null\n /**\n * The owning org's document, when the caller already holds it; read\n * here otherwise. The bands are resolved from it.\n */\n org?: Record<string, unknown> | null\n /**\n * A client-minted key for this attempt, so a double click makes one copy\n * and a retry of a finished attempt answers what the first did. A caller\n * with no client behind it — the AI runtime — sends none and is not\n * claimed.\n */\n attemptKey?: string | null\n}\n\nexport type DuplicateResourceResult =\n | {\n ok: true\n id: string\n /** The copy's first version, for the kinds that version. */\n versionId: string | null\n name: string\n }\n | { ok: false; status: number; error: string }\n\n/** What a sibling contributes to the three answers the siblings give. */\ninterface SiblingRow {\n id: string\n name: string\n slug: string\n kind: unknown\n deletedAt: unknown\n sourceType: unknown\n}\n\nconst asRecord = (value: unknown): Record<string, unknown> =>\n value && typeof value === 'object' && !Array.isArray(value)\n ? (value as Record<string, unknown>)\n : {}\n\n/**\n * Makes the copy. The caller has authenticated `uid` and verified that they\n * may write content on `hostId`; this decides everything after that.\n */\nexport async function duplicateResource(\n kind: DuplicableHostResourceKind,\n options: DuplicateResourceOptions,\n): Promise<DuplicateResourceResult> {\n const recipe = recipeFor(kind)\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n if (!recipe) return { ok: false, status: 400, error: 'That resource cannot be duplicated' }\n const sourceId = String(options.sourceId ?? '').trim().slice(0, 64)\n if (!sourceId) return { ok: false, status: 400, error: `Missing the ${noun} to copy` }\n const firestore = firebaseAdmin.app().firestore()\n\n const attemptKey = String(options.attemptKey ?? '').trim().slice(0, 200)\n const claimed = attemptKey\n ? await claimAttempt(firestore as never, {\n kind: `duplicate-${kind}`,\n scopeId: `${options.hostId}:${sourceId}`,\n orgId: options.orgId,\n key: attemptKey,\n busyMessage: DUPLICATE_BUSY_MESSAGE,\n })\n : null\n if (claimed && 'replay' in claimed) {\n return claimed.replay.body as DuplicateResourceResult\n }\n const claim = claimed && 'claim' in claimed ? claimed.claim : null\n\n try {\n const result = await copy(firestore, kind, recipe, { ...options, sourceId })\n if (result.ok) {\n await logResourceDuplicated(\n kind,\n { uid: options.uid, email: options.email ?? null },\n {\n orgId: options.orgId,\n hostId: options.hostId,\n source: { id: sourceId, name: result.sourceName },\n target: { id: result.id, name: result.name, versionId: result.versionId },\n },\n )\n const answer: DuplicateResourceResult = {\n ok: true,\n id: result.id,\n versionId: result.versionId,\n name: result.name,\n }\n await claim?.record(200, answer)\n return answer\n }\n // A refusal releases the key: the person fixes the cause — frees a slot,\n // picks another name — and presses the same button again.\n await claim?.release()\n return result\n } catch (error) {\n await claim?.release()\n throw error\n }\n}\n\ntype CopyOutcome =\n | { ok: true; id: string; versionId: string | null; name: string; sourceName: string }\n | { ok: false; status: number; error: string }\n\nasync function copy(\n firestore: FirebaseFirestore.Firestore,\n kind: DuplicableHostResourceKind,\n recipe: KindRecipe,\n options: DuplicateResourceOptions & { sourceId: string },\n): Promise<CopyOutcome> {\n const { hostId, sourceId, uid } = options\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const hostRef = firestore.collection('hosts').doc(hostId)\n const collectionRef = hostRef.collection(recipe.collection)\n const sourceRef = collectionRef.doc(sourceId)\n const org =\n options.org ??\n (((await firestore.collection('orgs').doc(options.orgId).get()).data() ??\n null) as Record<string, unknown> | null)\n\n if (recipe.entitlement && !checkEntitlement(org as never, recipe.entitlement)) {\n return {\n ok: false,\n status: 403,\n error: 'This feature is not included in your plan — see Billing',\n }\n }\n\n const id = createResourceUid()\n const versionId = recipe.versionParentField ? createResourceUid() : null\n\n return firestore.runTransaction(async (tx): Promise<CopyOutcome> => {\n // ALL READS BEFORE ANY WRITE, which Firestore requires. The host is read\n // inside the transaction rather than passed in because its routing map\n // is an input to the screen count and is ordinary client-writable.\n const siblingFields = [\n recipe.nameField,\n 'slug',\n 'kind',\n 'deletedAt',\n 'source.type',\n ]\n const [hostSnapshot, source, siblingsSnapshot] = await Promise.all([\n tx.get(hostRef),\n tx.get(sourceRef),\n tx.get(collectionRef.select(...siblingFields)),\n ])\n if (!hostSnapshot.exists) return { ok: false, status: 404, error: 'Unknown site' }\n if (!source.exists || source.get('deletedAt') != null) {\n return { ok: false, status: 404, error: `Unknown ${noun}` }\n }\n const data = asRecord(source.data())\n\n // The two screen kinds share a collection and are told apart by `kind`:\n // an email design copied as a page would land on the Screens list, and\n // the other way round would put a page on the Emails page.\n //\n // Of the screens that are not pages, only a collection's entry template\n // may be copied as a screen (AGL-3102), and the copy stays a template:\n // `kind` rides across with the recipe's fields, so it composes nothing\n // until a collection points at it — which is how a second collection\n // starts from the first one's design. An error screen is refused:\n // `kind: 'error'` is the slot assignment's to stamp, because that write\n // is what bounds how many a site holds, and a copy would stamp one\n // outside it.\n const sourceKind = data['kind']\n const nonPageScreen =\n recipe.collection === 'screens' &&\n !screenClaimsToBeAPage({ kind: sourceKind as string })\n if (recipe.collection === 'screens') {\n if (kind === 'emailDesign' && sourceKind !== SCREEN_KIND_EMAIL) {\n return { ok: false, status: 400, error: 'That page is not an email design' }\n }\n if (kind === 'screen' && nonPageScreen && sourceKind !== SCREEN_KIND_TEMPLATE) {\n return {\n ok: false,\n status: 400,\n error: 'Only a page or a collection entry template can be duplicated as a page',\n }\n }\n }\n\n const siblings: SiblingRow[] = siblingsSnapshot.docs.map((row) => ({\n id: row.id,\n name: String(row.get(recipe.nameField) ?? ''),\n slug: String(row.get('slug') ?? ''),\n kind: row.get('kind'),\n deletedAt: row.get('deletedAt'),\n sourceType: row.get('source.type'),\n }))\n const live = siblings.filter((row) => row.deletedAt == null)\n\n // THE BAND, met inside the transaction (AGL-2231) with the create route's\n // own arithmetic: screens count what the routing map makes billable,\n // templates exclude the platform's starters, everything else counts its\n // live documents. A screen copy that is not a page — an email design or\n // an entry template — meets the flat platform ceiling on non-page screens\n // instead, the one an email design's create meets and a demoted template\n // fills, and never the page band: it spends none of it.\n const screenRows: BillableScreenSource[] = siblings.map((row) => ({\n id: row.id,\n kind: row.kind,\n deletedAt: row.deletedAt,\n }))\n const routingMap = hostSnapshot.get('screens') as never\n if (nonPageScreen) {\n if (nonPageScreenIds(screenRows, routingMap).size >= NON_PAGE_SCREEN_MAX_PER_HOST) {\n return {\n ok: false,\n status: 403,\n error:\n 'This site is at its limit of ' +\n `${NON_PAGE_SCREEN_MAX_PER_HOST} email designs and template pages — ` +\n 'delete some to make room',\n }\n }\n } else if (recipe.quotaKey) {\n const used =\n kind === 'screen'\n ? billableScreenIds(screenRows, routingMap).size\n : kind === 'template'\n ? live.filter((row) => row.sourceType !== 'starter').length\n : live.length\n const quota = checkQuota(org as never, recipe.quotaKey as never, used)\n if (!quota.allowed) {\n return {\n ok: false,\n status: 403,\n error:\n `Your plan includes ${quota.limit} ${noun}${quota.limit === 1 ? '' : 's'} — ` +\n 'upgrade in Billing for more',\n }\n }\n }\n\n // THE NAME AND THE SLUG, unique among the live siblings. A deleted\n // sibling's name is free: a person who deleted \"Home\" and copies\n // \"About\" as \"Home\" is not confused by a tombstone.\n const sourceName = String(data[recipe.nameField] ?? '').trim()\n const requested = String(options.name ?? '').trim() || duplicateDisplayName(sourceName)\n const name = uniqueDuplicateName(\n requested,\n live.map((row) => row.name),\n )\n const slug = recipe.slug\n ? uniqueDuplicateSlug(\n data['slug'] as string | undefined,\n live.map((row) => row.slug),\n )\n : undefined\n\n // THE VERSION: the most recently saved one, else the one the document\n // points at. Read inside the transaction so the copy is of what was\n // saved when the count was taken.\n let versionData: Record<string, unknown> | null = null\n let sourceVersionNumber: number | null = null\n if (recipe.versionParentField) {\n const versionsRef = sourceRef.collection('versions')\n const [newest, all] = await Promise.all([\n tx.get(versionsRef.orderBy('updatedAt', 'desc').limit(1)),\n tx.get(versionsRef.select()),\n ])\n let found: FirebaseFirestore.DocumentSnapshot | null = newest.docs[0] ?? null\n const pointer = String(data['versionId'] ?? '')\n if (!found && pointer) {\n const pointed = await tx.get(versionsRef.doc(pointer))\n if (pointed.exists) found = pointed\n }\n if (found) {\n versionData = asRecord(found.data())\n sourceVersionNumber = all.size\n }\n }\n\n // THE DOCUMENT: the recipe's fields, the name, the slug, the stamps.\n const doc: Record<string, unknown> = {}\n for (const field of recipe.fields) {\n if (data[field] !== undefined) doc[field] = data[field]\n }\n doc[recipe.nameField] = name\n if (recipe.slug) {\n if (slug) doc['slug'] = slug\n else delete doc['slug']\n }\n /*\n * THE FORM'S BINDING, moved onto the copy (AGL-3024).\n *\n * A form's design names its own form in a prop, and that name is what\n * `/api/forms/submit` files a submission under. Copied verbatim it still\n * names the SOURCE, so the copy renders, collects, and files everything\n * it collects under the form it was copied from — while its own list\n * stays empty and nothing anywhere reports an error. `checkFormContract`\n * is what the console's form page banners it with; `formDesignReboundTo`\n * is the write side of the same fact.\n *\n * This is the one place a copied tree is not carried across untouched,\n * and the exception is narrow on purpose: no other kind here embeds its\n * own id in its design. `formDesignReboundTo` answers `null` when there\n * is nothing to move, so every other kind — and a form whose design is\n * already correct — pays nothing for this.\n */\n const rebound = (raw: unknown): unknown => {\n if (kind !== 'form') return raw\n return (\n formDesignReboundTo(decodeStoredNodes(raw), {\n formId: id,\n formName: name,\n }) ?? raw\n )\n }\n // The stored tree is msgpack at rest; `encodeStoredNodes` passes encoded\n // bytes through and packs a plain map, so a legacy plain source lands\n // compressed like every create does — and so does a rebound one, which\n // comes back decoded.\n const packed = encodeStoredNodes(rebound(doc['nodes']))\n if (packed) doc['nodes'] = Buffer.from(packed)\n tx.create(collectionRef.doc(id), {\n ...doc,\n ...(recipe.stamps ?? {}),\n // The keys the copy's list finds it by (AGL-3321), from the copy's own\n // name and stamps — see `artifactCreateListKeys`.\n ...artifactCreateListKeys(recipe.collection, { ...doc, ...(recipe.stamps ?? {}) }),\n // A copied form starts as a new one does, with its own search keys and\n // nothing counted (AGL-3330); only the routing it copied is kept.\n ...(kind === 'form'\n ? newFormListFields({\n id,\n displayName: name,\n slug: doc['slug'],\n routing: doc['routing'] as Parameters<typeof newFormListFields>[0]['routing'],\n })\n : {}),\n ...(versionData && versionId ? { versionId } : {}),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n if (versionData && versionId && recipe.versionParentField) {\n const versionNodes = encodeStoredNodes(rebound(versionData['nodes']))\n tx.create(collectionRef.doc(id).collection('versions').doc(versionId), {\n ...versionData,\n ...(versionNodes ? { nodes: Buffer.from(versionNodes) } : {}),\n [recipe.versionParentField]: id,\n hostId,\n displayName: duplicateVersionNote(sourceName, sourceVersionNumber),\n createdAt: Timestamp.now(),\n updatedAt: Timestamp.now(),\n createdBy: uid,\n })\n }\n return {\n ok: true,\n id,\n versionId: versionData && versionId ? versionId : null,\n name,\n sourceName,\n }\n })\n}\n"],"names":["artifactCreateListKeys","billableScreenIds","checkEntitlement","checkQuota","claimAttempt","createResourceUid","decodeStoredNodes","encodeStoredNodes","newFormListFields","NON_PAGE_SCREEN_MAX_PER_HOST","nonPageScreenIds","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","screenClaimsToBeAPage","DUPLICABLE_RESOURCE_NOUNS","DUPLICATE_BUSY_MESSAGE","duplicateDisplayName","duplicateVersionNote","uniqueDuplicateName","uniqueDuplicateSlug","formDesignReboundTo","pluginHostResource","Timestamp","logResourceDuplicated","firebaseAdmin","RECIPES","screen","collection","nameField","fields","versionParentField","quotaKey","slug","emailDesign","component","entitlement","layout","template","stamps","source","type","form","recipeFor","kind","Object","prototype","hasOwnProperty","call","declared","duplicate","asRecord","value","Array","isArray","duplicateResource","options","recipe","noun","ok","status","error","sourceId","String","trim","slice","firestore","app","attemptKey","claimed","scopeId","hostId","orgId","key","busyMessage","replay","body","claim","result","copy","uid","email","id","name","sourceName","target","versionId","answer","record","release","hostRef","doc","collectionRef","sourceRef","org","get","data","runTransaction","tx","siblingFields","hostSnapshot","siblingsSnapshot","Promise","all","select","exists","sourceKind","nonPageScreen","siblings","docs","map","row","deletedAt","sourceType","live","filter","screenRows","routingMap","size","used","length","quota","allowed","limit","requested","undefined","versionData","sourceVersionNumber","newest","versionsRef","orderBy","found","pointer","pointed","field","rebound","raw","formId","formName","packed","Buffer","from","create","displayName","routing","createdAt","now","updatedAt","createdBy","versionNodes","nodes"],"mappings":"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"}
|
|
@@ -49,7 +49,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
49
49
|
* postpones enrollments; it never removes anybody, and it never finishes a
|
|
50
50
|
* sweep it did not complete.
|
|
51
51
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
52
|
-
import { soloConsentGroup, candidateMatchesDynamicListRule, dynamicListRuleIsEmpty, dynamicListRuleListIds, dynamicListRuleWithoutListReference, dynamicListRuleNeedsContactFacet, dynamicListDimensionsForCrmView, dynamicListPlanningRule, CRM_COLLECTIONS, normalizeCrmViewFilters, dynamicListRuleNeedsEngagement, extractEmailFromFields, isContactLifecycleStage, normalizeContactEmail, normalizeDynamicListRule, personKey, readCampaignIds, readContactCampaignIds, readContactFacet } from "@aglyn/aglyn/server";
|
|
52
|
+
import { soloConsentGroup, candidateMatchesDynamicListRule, dynamicListRuleIsEmpty, dynamicListRuleListIds, dynamicListRuleWithoutListReference, dynamicListRuleNeedsContactFacet, dynamicListDimensionsForCrmView, dynamicListPlanningRule, CRM_COLLECTIONS, normalizeCrmViewFilters, dynamicListRuleNeedsEngagement, extractEmailFromFields, seenOnlyThroughGrant, isContactLifecycleStage, normalizeContactEmail, normalizeDynamicListRule, personKey, readCampaignIds, readContactCampaignIds, readContactFacet } from "@aglyn/aglyn/server";
|
|
53
53
|
import { consentGroupHoldersInMotion } from "@aglyn/aglyn/app-utils/consent-group-change";
|
|
54
54
|
import { firebaseAdmin } from "./firebase-admin.js";
|
|
55
55
|
import { readPersonEngagementByKeys } from "./email-delivery-log.js";
|
|
@@ -428,6 +428,9 @@ const millis = (value)=>{
|
|
|
428
428
|
for (const doc of snapshot.docs){
|
|
429
429
|
read += 1;
|
|
430
430
|
after = doc.id;
|
|
431
|
+
// A contact another site SHARED with this one (AGL-3336) is seen
|
|
432
|
+
// here, not held: it is never this site's audience.
|
|
433
|
+
if (source === 'contacts' && seenOnlyThroughGrant(doc.data(), options.hostId)) continue;
|
|
431
434
|
const candidate = toCandidate(source, doc, facetGroupId);
|
|
432
435
|
if (candidate) pageCandidates.push(candidate);
|
|
433
436
|
}
|