@aglyn/tenant-feature-instance 1.0.0-beta.209 → 1.0.0-beta.212

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-feature-instance",
3
- "version": "1.0.0-beta.209",
3
+ "version": "1.0.0-beta.212",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.209",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.209",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.209",
31
- "@aglyn/shared-util-email": "1.0.0-beta.209",
32
- "@aglyn/shared-util-http": "1.0.0-beta.209",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.209",
28
+ "@aglyn/aglyn": "1.0.0-beta.212",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.212",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.212",
31
+ "@aglyn/shared-util-email": "1.0.0-beta.212",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.212",
33
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.212",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "lodash-es": "^4.18.1"
36
36
  },
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type DocumentReference, type Firestore, type WriteBatch } from 'firebase/firestore';
18
+ /**
19
+ * A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES
20
+ * (AGL-3386).
21
+ *
22
+ * A site's settings, and the plugin data its pages render — the theme, the
23
+ * announcement bar, a product, a site variable — are client Firestore writes
24
+ * with no publish step. The live site is ISR-cached for an hour, so each of
25
+ * those writes has to tell the tenant to drop the site's cached pages, or the
26
+ * change waits out the hour while the console says it is saved.
27
+ *
28
+ * Telling it from the tab is the fast path, and a tab is not durable: close
29
+ * it, sleep the laptop or lose the network between the write and the fetch,
30
+ * and the write has landed while the drop has landed nowhere. So the drop is
31
+ * also WRITTEN DOWN, as a publish outbox entry in the same batch as the
32
+ * write — either both land or neither does — and the console's scheduled
33
+ * drain fires any entry the tab did not get to release. This is the same
34
+ * mechanism a screen publish uses (AGL-2575); the entry carries
35
+ * `entireHost: true`, because a site-wide setting has no address of its own.
36
+ *
37
+ * Shared here rather than kept in the console because plugins write these
38
+ * documents too, and a plugin cannot import the console.
39
+ */
40
+ /**
41
+ * The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same
42
+ * collection, and `host-document-writes-sweep.spec.ts` holds the two names
43
+ * and the entry's keys to the console's constants and to the rule.
44
+ */
45
+ export declare const SITE_WIDE_OUTBOX_COLLECTION = "publishOutbox";
46
+ /**
47
+ * What a site-wide entry names. The rule insists every entry names at least
48
+ * one address; the drain reads `entireHost` and drops every routed page of
49
+ * the site instead, which is what a site-wide change needs.
50
+ */
51
+ export declare const SITE_WIDE_OUTBOX_PATHS: readonly string[];
52
+ /**
53
+ * What an entry can be staged into: a batch, or a transaction — anything
54
+ * whose `set` lands in the same commit as the write beside it.
55
+ */
56
+ export interface SiteWideEntryWriter {
57
+ set(ref: DocumentReference, data: Record<string, unknown>): unknown;
58
+ }
59
+ /** Stages one site-wide outbox entry into the caller's batch. */
60
+ export declare function stageSiteWideOutboxEntry(batch: SiteWideEntryWriter, firestore: Firestore, hostId: string): DocumentReference;
61
+ /** Adds a site-wide outbox entry to a batch that is about to commit. */
62
+ export type StageSiteWideEntry = (batch: SiteWideEntryWriter, firestore: Firestore) => void;
63
+ /**
64
+ * Commits a write with its site-wide outbox entry beside it, and answers the
65
+ * entry — or `null` when the write had to land without one.
66
+ *
67
+ * `commit` performs the write. It is handed the stager on the first attempt
68
+ * and must put the entry in the SAME batch as the write; it is called again
69
+ * without one if that batch is refused.
70
+ *
71
+ * The retry is what keeps the entry from ever costing a save. The entry is
72
+ * the durable copy of a cache drop, and the write is the user's work: a rule
73
+ * that refuses the entry — the window between a code release and the rules
74
+ * deploy that admits it, or a role the outbox rule does not cover — must
75
+ * leave the save exactly as it was without it, with the tab's own drop still
76
+ * behind it. A write the rules refuse on its own merits is refused again,
77
+ * and that second refusal is the one the caller sees.
78
+ */
79
+ export declare function commitWithSiteWideEntry(hostId: string, commit: (stage: StageSiteWideEntry | undefined) => Promise<void>): Promise<DocumentReference | null>;
80
+ /**
81
+ * Releases an entry once its drop is KNOWN to have landed — a plain `ok` from
82
+ * the console route and nothing else. Any other answer leaves it for the
83
+ * drain, because the entry is the only record that the site may still be
84
+ * stale; the cost of keeping one needlessly is a duplicate drop, which is a
85
+ * no-op.
86
+ */
87
+ export declare function releaseSiteWideOutboxEntry(entry: DocumentReference | null, reason: string | null | undefined): Promise<void>;
88
+ /**
89
+ * Asks the console to drop every cached page of the site, and answers its
90
+ * reason — `'ok'` only when the drop landed.
91
+ *
92
+ * The plugin-side door to the console's own `/api/screens/revalidate`, which
93
+ * a plugin cannot import. Never throws: a cache hint that fails must not make
94
+ * a successful save look failed, and the outbox entry and the hour-long
95
+ * window are both underneath it.
96
+ */
97
+ export declare function announceSiteWideChange(options: {
98
+ /** The signed-in user; its ID token authenticates the console route. */
99
+ user: {
100
+ getIdToken?: () => Promise<string>;
101
+ } | null | undefined;
102
+ hostId: string;
103
+ }): Promise<string | null>;
104
+ /**
105
+ * After the write has landed: ask for the drop, and release the entry when it
106
+ * lands. Fire and forget — it never rejects.
107
+ */
108
+ export declare function settleSiteWideChange(options: {
109
+ user: {
110
+ getIdToken?: () => Promise<string>;
111
+ } | null | undefined;
112
+ hostId: string;
113
+ entry: DocumentReference | null;
114
+ }): void;
115
+ /**
116
+ * THE ONE CALL A PLUGIN'S CONSOLE CARD MAKES TO SAVE SOMETHING A PUBLISHED
117
+ * PAGE RENDERS.
118
+ *
119
+ * `write` stages the card's writes into the batch it is handed —
120
+ * `batch.update(ref, …)`, `batch.set(ref, …, { merge: true })`,
121
+ * `batch.delete(ref)` — and the batch commits with the site-wide outbox entry
122
+ * beside them. Resolves when the write has landed, rejects exactly when it
123
+ * did not; the drop runs behind it and never rejects.
124
+ */
125
+ export declare function writeSiteWideChange(options: {
126
+ firestore: Firestore;
127
+ user: {
128
+ getIdToken?: () => Promise<string>;
129
+ } | null | undefined;
130
+ hostId: string;
131
+ write: (batch: WriteBatch) => void;
132
+ }): Promise<void>;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
17
+ import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
18
+ import { deleteDoc, doc, serverTimestamp, writeBatch } from "firebase/firestore";
19
+ /**
20
+ * A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES
21
+ * (AGL-3386).
22
+ *
23
+ * A site's settings, and the plugin data its pages render — the theme, the
24
+ * announcement bar, a product, a site variable — are client Firestore writes
25
+ * with no publish step. The live site is ISR-cached for an hour, so each of
26
+ * those writes has to tell the tenant to drop the site's cached pages, or the
27
+ * change waits out the hour while the console says it is saved.
28
+ *
29
+ * Telling it from the tab is the fast path, and a tab is not durable: close
30
+ * it, sleep the laptop or lose the network between the write and the fetch,
31
+ * and the write has landed while the drop has landed nowhere. So the drop is
32
+ * also WRITTEN DOWN, as a publish outbox entry in the same batch as the
33
+ * write — either both land or neither does — and the console's scheduled
34
+ * drain fires any entry the tab did not get to release. This is the same
35
+ * mechanism a screen publish uses (AGL-2575); the entry carries
36
+ * `entireHost: true`, because a site-wide setting has no address of its own.
37
+ *
38
+ * Shared here rather than kept in the console because plugins write these
39
+ * documents too, and a plugin cannot import the console.
40
+ */ /**
41
+ * The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same
42
+ * collection, and `host-document-writes-sweep.spec.ts` holds the two names
43
+ * and the entry's keys to the console's constants and to the rule.
44
+ */ export const SITE_WIDE_OUTBOX_COLLECTION = 'publishOutbox';
45
+ /**
46
+ * What a site-wide entry names. The rule insists every entry names at least
47
+ * one address; the drain reads `entireHost` and drops every routed page of
48
+ * the site instead, which is what a site-wide change needs.
49
+ */ export const SITE_WIDE_OUTBOX_PATHS = [
50
+ '/'
51
+ ];
52
+ /** Stages one site-wide outbox entry into the caller's batch. */ export function stageSiteWideOutboxEntry(batch, firestore, hostId) {
53
+ // Named by the id every console resource carries, never an SDK auto-id.
54
+ const ref = doc(firestore, SITE_WIDE_OUTBOX_COLLECTION, createResourceUid());
55
+ batch.set(ref, {
56
+ hostId,
57
+ paths: [
58
+ ...SITE_WIDE_OUTBOX_PATHS
59
+ ],
60
+ // A server timestamp: the rules pin it to `request.time`, and the drain
61
+ // ages entries by it.
62
+ createdAt: serverTimestamp(),
63
+ attempts: 0,
64
+ entireHost: true
65
+ });
66
+ return ref;
67
+ }
68
+ const isPermissionDenied = (error)=>(error == null ? void 0 : error.code) === 'permission-denied';
69
+ /**
70
+ * Commits a write with its site-wide outbox entry beside it, and answers the
71
+ * entry — or `null` when the write had to land without one.
72
+ *
73
+ * `commit` performs the write. It is handed the stager on the first attempt
74
+ * and must put the entry in the SAME batch as the write; it is called again
75
+ * without one if that batch is refused.
76
+ *
77
+ * The retry is what keeps the entry from ever costing a save. The entry is
78
+ * the durable copy of a cache drop, and the write is the user's work: a rule
79
+ * that refuses the entry — the window between a code release and the rules
80
+ * deploy that admits it, or a role the outbox rule does not cover — must
81
+ * leave the save exactly as it was without it, with the tab's own drop still
82
+ * behind it. A write the rules refuse on its own merits is refused again,
83
+ * and that second refusal is the one the caller sees.
84
+ */ export async function commitWithSiteWideEntry(hostId, commit) {
85
+ // A holder rather than a `let`: the stager assigns it inside a callback,
86
+ // which control-flow narrowing cannot see.
87
+ const staged = {
88
+ entry: null
89
+ };
90
+ try {
91
+ await commit((batch, firestore)=>{
92
+ staged.entry = stageSiteWideOutboxEntry(batch, firestore, hostId);
93
+ });
94
+ return staged.entry;
95
+ } catch (error) {
96
+ if (!staged.entry || !isPermissionDenied(error)) throw error;
97
+ await commit(undefined);
98
+ return null;
99
+ }
100
+ }
101
+ /**
102
+ * Releases an entry once its drop is KNOWN to have landed — a plain `ok` from
103
+ * the console route and nothing else. Any other answer leaves it for the
104
+ * drain, because the entry is the only record that the site may still be
105
+ * stale; the cost of keeping one needlessly is a duplicate drop, which is a
106
+ * no-op.
107
+ */ export async function releaseSiteWideOutboxEntry(entry, reason) {
108
+ if (!entry || reason !== 'ok') return;
109
+ await deleteDoc(entry).catch(()=>undefined);
110
+ }
111
+ /**
112
+ * Asks the console to drop every cached page of the site, and answers its
113
+ * reason — `'ok'` only when the drop landed.
114
+ *
115
+ * The plugin-side door to the console's own `/api/screens/revalidate`, which
116
+ * a plugin cannot import. Never throws: a cache hint that fails must not make
117
+ * a successful save look failed, and the outbox entry and the hour-long
118
+ * window are both underneath it.
119
+ */ export async function announceSiteWideChange(options) {
120
+ const { user, hostId } = options;
121
+ if (!hostId) return null;
122
+ try {
123
+ const response = await authorizedFetch(user, '/api/screens/revalidate', {
124
+ method: 'POST',
125
+ headers: {
126
+ 'Content-Type': 'application/json'
127
+ },
128
+ body: JSON.stringify({
129
+ hostId,
130
+ entireHost: true
131
+ })
132
+ });
133
+ if (!response.ok) return `console-${response.status}`;
134
+ const body = await response.json().catch(()=>null);
135
+ return typeof (body == null ? void 0 : body.reason) === 'string' ? body.reason : 'ok';
136
+ } catch (unused) {
137
+ return null;
138
+ }
139
+ }
140
+ /**
141
+ * After the write has landed: ask for the drop, and release the entry when it
142
+ * lands. Fire and forget — it never rejects.
143
+ */ export function settleSiteWideChange(options) {
144
+ const { user, hostId, entry } = options;
145
+ void announceSiteWideChange({
146
+ user,
147
+ hostId
148
+ }).then((reason)=>releaseSiteWideOutboxEntry(entry, reason)).catch(()=>undefined);
149
+ }
150
+ /**
151
+ * THE ONE CALL A PLUGIN'S CONSOLE CARD MAKES TO SAVE SOMETHING A PUBLISHED
152
+ * PAGE RENDERS.
153
+ *
154
+ * `write` stages the card's writes into the batch it is handed —
155
+ * `batch.update(ref, …)`, `batch.set(ref, …, { merge: true })`,
156
+ * `batch.delete(ref)` — and the batch commits with the site-wide outbox entry
157
+ * beside them. Resolves when the write has landed, rejects exactly when it
158
+ * did not; the drop runs behind it and never rejects.
159
+ */ export async function writeSiteWideChange(options) {
160
+ const { firestore, user, hostId, write } = options;
161
+ const entry = await commitWithSiteWideEntry(hostId, async (stage)=>{
162
+ const batch = writeBatch(firestore);
163
+ write(batch);
164
+ stage == null ? void 0 : stage(batch, firestore);
165
+ await batch.commit();
166
+ });
167
+ settleSiteWideChange({
168
+ user,
169
+ hostId,
170
+ entry
171
+ });
172
+ }
173
+
174
+ //# sourceMappingURL=site-wide-change.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/site-wide-change.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport {\n deleteDoc,\n doc,\n serverTimestamp,\n writeBatch,\n type DocumentReference,\n type Firestore,\n type WriteBatch,\n} from 'firebase/firestore'\n\n/**\n * A SITE-WIDE CHANGE, SAVED FROM THE BROWSER, AND THE CACHE DROP IT OWES\n * (AGL-3386).\n *\n * A site's settings, and the plugin data its pages render — the theme, the\n * announcement bar, a product, a site variable — are client Firestore writes\n * with no publish step. The live site is ISR-cached for an hour, so each of\n * those writes has to tell the tenant to drop the site's cached pages, or the\n * change waits out the hour while the console says it is saved.\n *\n * Telling it from the tab is the fast path, and a tab is not durable: close\n * it, sleep the laptop or lose the network between the write and the fetch,\n * and the write has landed while the drop has landed nowhere. So the drop is\n * also WRITTEN DOWN, as a publish outbox entry in the same batch as the\n * write — either both land or neither does — and the console's scheduled\n * drain fires any entry the tab did not get to release. This is the same\n * mechanism a screen publish uses (AGL-2575); the entry carries\n * `entireHost: true`, because a site-wide setting has no address of its own.\n *\n * Shared here rather than kept in the console because plugins write these\n * documents too, and a plugin cannot import the console.\n */\n\n/**\n * The publish outbox. The console's `PUBLISH_OUTBOX_COLLECTION` is the same\n * collection, and `host-document-writes-sweep.spec.ts` holds the two names\n * and the entry's keys to the console's constants and to the rule.\n */\nexport const SITE_WIDE_OUTBOX_COLLECTION = 'publishOutbox'\n\n/**\n * What a site-wide entry names. The rule insists every entry names at least\n * one address; the drain reads `entireHost` and drops every routed page of\n * the site instead, which is what a site-wide change needs.\n */\nexport const SITE_WIDE_OUTBOX_PATHS: readonly string[] = ['/']\n\n/**\n * What an entry can be staged into: a batch, or a transaction — anything\n * whose `set` lands in the same commit as the write beside it.\n */\nexport interface SiteWideEntryWriter {\n set(ref: DocumentReference, data: Record<string, unknown>): unknown\n}\n\n/** Stages one site-wide outbox entry into the caller's batch. */\nexport function stageSiteWideOutboxEntry(\n batch: SiteWideEntryWriter,\n firestore: Firestore,\n hostId: string,\n): DocumentReference {\n // Named by the id every console resource carries, never an SDK auto-id.\n const ref = doc(firestore, SITE_WIDE_OUTBOX_COLLECTION, createResourceUid())\n batch.set(ref, {\n hostId,\n paths: [...SITE_WIDE_OUTBOX_PATHS],\n // A server timestamp: the rules pin it to `request.time`, and the drain\n // ages entries by it.\n createdAt: serverTimestamp(),\n attempts: 0,\n entireHost: true,\n })\n return ref\n}\n\n/** Adds a site-wide outbox entry to a batch that is about to commit. */\nexport type StageSiteWideEntry = (\n batch: SiteWideEntryWriter,\n firestore: Firestore,\n) => void\n\nconst isPermissionDenied = (error: unknown): boolean =>\n (error as { code?: unknown } | null)?.code === 'permission-denied'\n\n/**\n * Commits a write with its site-wide outbox entry beside it, and answers the\n * entry — or `null` when the write had to land without one.\n *\n * `commit` performs the write. It is handed the stager on the first attempt\n * and must put the entry in the SAME batch as the write; it is called again\n * without one if that batch is refused.\n *\n * The retry is what keeps the entry from ever costing a save. The entry is\n * the durable copy of a cache drop, and the write is the user's work: a rule\n * that refuses the entry — the window between a code release and the rules\n * deploy that admits it, or a role the outbox rule does not cover — must\n * leave the save exactly as it was without it, with the tab's own drop still\n * behind it. A write the rules refuse on its own merits is refused again,\n * and that second refusal is the one the caller sees.\n */\nexport async function commitWithSiteWideEntry(\n hostId: string,\n commit: (stage: StageSiteWideEntry | undefined) => Promise<void>,\n): Promise<DocumentReference | null> {\n // A holder rather than a `let`: the stager assigns it inside a callback,\n // which control-flow narrowing cannot see.\n const staged: { entry: DocumentReference | null } = { entry: null }\n try {\n await commit((batch, firestore) => {\n staged.entry = stageSiteWideOutboxEntry(batch, firestore, hostId)\n })\n return staged.entry\n } catch (error) {\n if (!staged.entry || !isPermissionDenied(error)) throw error\n await commit(undefined)\n return null\n }\n}\n\n/**\n * Releases an entry once its drop is KNOWN to have landed — a plain `ok` from\n * the console route and nothing else. Any other answer leaves it for the\n * drain, because the entry is the only record that the site may still be\n * stale; the cost of keeping one needlessly is a duplicate drop, which is a\n * no-op.\n */\nexport async function releaseSiteWideOutboxEntry(\n entry: DocumentReference | null,\n reason: string | null | undefined,\n): Promise<void> {\n if (!entry || reason !== 'ok') return\n await deleteDoc(entry).catch(() => undefined)\n}\n\n/**\n * Asks the console to drop every cached page of the site, and answers its\n * reason — `'ok'` only when the drop landed.\n *\n * The plugin-side door to the console's own `/api/screens/revalidate`, which\n * a plugin cannot import. Never throws: a cache hint that fails must not make\n * a successful save look failed, and the outbox entry and the hour-long\n * window are both underneath it.\n */\nexport async function announceSiteWideChange(options: {\n /** The signed-in user; its ID token authenticates the console route. */\n user: { getIdToken?: () => Promise<string> } | null | undefined\n hostId: string\n}): Promise<string | null> {\n const { user, hostId } = options\n if (!hostId) return null\n try {\n const response = await authorizedFetch(user, '/api/screens/revalidate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, entireHost: true }),\n })\n if (!response.ok) return `console-${response.status}`\n const body = (await response.json().catch(() => null)) as {\n reason?: unknown\n } | null\n return typeof body?.reason === 'string' ? body.reason : 'ok'\n } catch {\n return null\n }\n}\n\n/**\n * After the write has landed: ask for the drop, and release the entry when it\n * lands. Fire and forget — it never rejects.\n */\nexport function settleSiteWideChange(options: {\n user: { getIdToken?: () => Promise<string> } | null | undefined\n hostId: string\n entry: DocumentReference | null\n}): void {\n const { user, hostId, entry } = options\n void announceSiteWideChange({ user, hostId })\n .then((reason) => releaseSiteWideOutboxEntry(entry, reason))\n .catch(() => undefined)\n}\n\n/**\n * THE ONE CALL A PLUGIN'S CONSOLE CARD MAKES TO SAVE SOMETHING A PUBLISHED\n * PAGE RENDERS.\n *\n * `write` stages the card's writes into the batch it is handed —\n * `batch.update(ref, …)`, `batch.set(ref, …, { merge: true })`,\n * `batch.delete(ref)` — and the batch commits with the site-wide outbox entry\n * beside them. Resolves when the write has landed, rejects exactly when it\n * did not; the drop runs behind it and never rejects.\n */\nexport async function writeSiteWideChange(options: {\n firestore: Firestore\n user: { getIdToken?: () => Promise<string> } | null | undefined\n hostId: string\n write: (batch: WriteBatch) => void\n}): Promise<void> {\n const { firestore, user, hostId, write } = options\n const entry = await commitWithSiteWideEntry(hostId, async (stage) => {\n const batch = writeBatch(firestore)\n write(batch)\n stage?.(batch, firestore)\n await batch.commit()\n })\n settleSiteWideChange({ user, hostId, entry })\n}\n"],"names":["createResourceUid","authorizedFetch","deleteDoc","doc","serverTimestamp","writeBatch","SITE_WIDE_OUTBOX_COLLECTION","SITE_WIDE_OUTBOX_PATHS","stageSiteWideOutboxEntry","batch","firestore","hostId","ref","set","paths","createdAt","attempts","entireHost","isPermissionDenied","error","code","commitWithSiteWideEntry","commit","staged","entry","undefined","releaseSiteWideOutboxEntry","reason","catch","announceSiteWideChange","options","user","response","method","headers","body","JSON","stringify","ok","status","json","settleSiteWideChange","then","writeSiteWideChange","write","stage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,iBAAiB,QAAQ,6CAA4C;AAC9E,SAASC,eAAe,QAAQ,2CAA0C;AAC1E,SACEC,SAAS,EACTC,GAAG,EACHC,eAAe,EACfC,UAAU,QAIL,qBAAoB;AAE3B;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED;;;;CAIC,GACD,OAAO,MAAMC,8BAA8B,gBAAe;AAE1D;;;;CAIC,GACD,OAAO,MAAMC,yBAA4C;IAAC;CAAI,CAAA;AAU9D,+DAA+D,GAC/D,OAAO,SAASC,yBACdC,KAA0B,EAC1BC,SAAoB,EACpBC,MAAc;IAEd,wEAAwE;IACxE,MAAMC,MAAMT,IAAIO,WAAWJ,6BAA6BN;IACxDS,MAAMI,GAAG,CAACD,KAAK;QACbD;QACAG,OAAO;eAAIP;SAAuB;QAClC,wEAAwE;QACxE,sBAAsB;QACtBQ,WAAWX;QACXY,UAAU;QACVC,YAAY;IACd;IACA,OAAOL;AACT;AAQA,MAAMM,qBAAqB,CAACC,QAC1B,CAACA,yBAAD,AAACA,MAAqCC,IAAI,MAAK;AAEjD;;;;;;;;;;;;;;;CAeC,GACD,OAAO,eAAeC,wBACpBV,MAAc,EACdW,MAAgE;IAEhE,yEAAyE;IACzE,2CAA2C;IAC3C,MAAMC,SAA8C;QAAEC,OAAO;IAAK;IAClE,IAAI;QACF,MAAMF,OAAO,CAACb,OAAOC;YACnBa,OAAOC,KAAK,GAAGhB,yBAAyBC,OAAOC,WAAWC;QAC5D;QACA,OAAOY,OAAOC,KAAK;IACrB,EAAE,OAAOL,OAAO;QACd,IAAI,CAACI,OAAOC,KAAK,IAAI,CAACN,mBAAmBC,QAAQ,MAAMA;QACvD,MAAMG,OAAOG;QACb,OAAO;IACT;AACF;AAEA;;;;;;CAMC,GACD,OAAO,eAAeC,2BACpBF,KAA+B,EAC/BG,MAAiC;IAEjC,IAAI,CAACH,SAASG,WAAW,MAAM;IAC/B,MAAMzB,UAAUsB,OAAOI,KAAK,CAAC,IAAMH;AACrC;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeI,uBAAuBC,OAI5C;IACC,MAAM,EAAEC,IAAI,EAAEpB,MAAM,EAAE,GAAGmB;IACzB,IAAI,CAACnB,QAAQ,OAAO;IACpB,IAAI;QACF,MAAMqB,WAAW,MAAM/B,gBAAgB8B,MAAM,2BAA2B;YACtEE,QAAQ;YACRC,SAAS;gBAAE,gBAAgB;YAAmB;YAC9CC,MAAMC,KAAKC,SAAS,CAAC;gBAAE1B;gBAAQM,YAAY;YAAK;QAClD;QACA,IAAI,CAACe,SAASM,EAAE,EAAE,OAAO,CAAC,QAAQ,EAAEN,SAASO,MAAM,EAAE;QACrD,MAAMJ,OAAQ,MAAMH,SAASQ,IAAI,GAAGZ,KAAK,CAAC,IAAM;QAGhD,OAAO,QAAOO,wBAAAA,KAAMR,MAAM,MAAK,WAAWQ,KAAKR,MAAM,GAAG;IAC1D,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,SAASc,qBAAqBX,OAIpC;IACC,MAAM,EAAEC,IAAI,EAAEpB,MAAM,EAAEa,KAAK,EAAE,GAAGM;IAChC,KAAKD,uBAAuB;QAAEE;QAAMpB;IAAO,GACxC+B,IAAI,CAAC,CAACf,SAAWD,2BAA2BF,OAAOG,SACnDC,KAAK,CAAC,IAAMH;AACjB;AAEA;;;;;;;;;CASC,GACD,OAAO,eAAekB,oBAAoBb,OAKzC;IACC,MAAM,EAAEpB,SAAS,EAAEqB,IAAI,EAAEpB,MAAM,EAAEiC,KAAK,EAAE,GAAGd;IAC3C,MAAMN,QAAQ,MAAMH,wBAAwBV,QAAQ,OAAOkC;QACzD,MAAMpC,QAAQJ,WAAWK;QACzBkC,MAAMnC;QACNoC,yBAAAA,MAAQpC,OAAOC;QACf,MAAMD,MAAMa,MAAM;IACpB;IACAmB,qBAAqB;QAAEV;QAAMpB;QAAQa;IAAM;AAC7C"}
@@ -14,11 +14,19 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import { type DocumentReference, type SetOptions, type UpdateData } from 'firebase/firestore';
17
+ import { type DocumentReference, type Firestore, type SetOptions, type UpdateData, type WriteBatch } from 'firebase/firestore';
18
18
  export type UpdateDocCallback<T> = (data: UpdateData<T>) => Promise<void>;
19
19
  export type SetDocCallback<T> = (data: Partial<T>, options?: SetOptions) => Promise<void>;
20
20
  export type ModifyDocOptions = SetOptions & {
21
21
  shouldSet?: boolean;
22
+ /**
23
+ * Other writes that must land in the SAME commit as this one, or not at
24
+ * all. Given, the write goes through a batch — the same stamp, strip and
25
+ * converter as without it — with whatever this stages beside it. The
26
+ * console uses it to write a site-wide cache drop down durably next to a
27
+ * settings save (AGL-3386).
28
+ */
29
+ alongside?: (batch: WriteBatch, firestore: Firestore) => void;
22
30
  };
23
31
  export type ModifyDocCallback<T> = (data: UpdateData<T> | Partial<T>, options?: ModifyDocOptions) => Promise<void>;
24
32
  export declare function useUpdateDocCallback<T>(ref: DocumentReference<T>): UpdateDocCallback<T>;
@@ -1,4 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
2
3
  /**
3
4
  * @license
4
5
  * Copyright 2022 Aglyn LLC
@@ -15,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
16
  * See the License for the specific language governing permissions and
16
17
  * limitations under the License.
17
18
  */ import { HOST_UNPERSISTED_FIELDS, ORG_UNPERSISTED_FIELDS } from "@aglyn/aglyn";
18
- import { serverTimestamp, setDoc, updateDoc } from "firebase/firestore";
19
+ import { serverTimestamp, setDoc, updateDoc, writeBatch } from "firebase/firestore";
19
20
  import { useCallback } from "react";
20
21
  /**
21
22
  * Every UI write stamps `updatedAt` (AGL-455) — the "last updated" columns
@@ -98,9 +99,27 @@ export function useModifyDocCallback(ref) {
98
99
  // a payload. Anything that must hold for both branches belongs above
99
100
  // it, which is where `dropUnpersistedFields` now runs.
100
101
  const shouldSet = (options == null ? void 0 : options.shouldSet) || options && 'merge' in options || options && 'mergeFields' in options;
102
+ if (options == null ? void 0 : options.alongside) {
103
+ // `shouldSet` is this hook's own flag, never a Firestore option.
104
+ const { alongside, shouldSet: _flag } = options, setOptions = _object_without_properties_loose(options, [
105
+ "alongside",
106
+ "shouldSet"
107
+ ]);
108
+ void _flag;
109
+ const batch = writeBatch(ref.firestore);
110
+ const payload = stampUpdatedAt(dropUnpersistedFields(data));
111
+ // `batch.set` applies the ref's converter exactly as `setDoc` does,
112
+ // and `batch.update` skips it exactly as `updateDoc` does — so the
113
+ // batched write stores what the unbatched one would have.
114
+ if (shouldSet) batch.set(ref, payload, setOptions);
115
+ else batch.update(ref, payload);
116
+ alongside(batch, ref.firestore);
117
+ return batch.commit();
118
+ }
101
119
  if (shouldSet) return setDocCb(data, options);
102
120
  return updateDocCb(data);
103
121
  }, [
122
+ ref,
104
123
  updateDocCb,
105
124
  setDocCb
106
125
  ]);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/use-modify-doc-callback.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HOST_UNPERSISTED_FIELDS, ORG_UNPERSISTED_FIELDS } from '@aglyn/aglyn'\nimport {\n type DocumentReference,\n serverTimestamp,\n setDoc,\n type SetOptions,\n type UpdateData,\n updateDoc,\n} from 'firebase/firestore'\nimport { useCallback } from 'react'\n\n/**\n * Every UI write stamps `updatedAt` (AGL-455) — the \"last updated\" columns\n * read it, and callers historically forgot it. Spread order lets a caller's\n * explicit `updatedAt` win (backfills, imports).\n */\nfunction stampUpdatedAt<D extends object>(data: D): D {\n return { updatedAt: serverTimestamp(), ...data }\n}\n\n/**\n * Keys that are never document fields, taken from where that is written down\n * (AGL-1429). `HOST_UNPERSISTED_FIELDS` and `ORG_UNPERSISTED_FIELDS` each\n * declare the synthetic `$id` the readers attach; the INTERSECTION is used\n * deliberately, so this generic boundary only ever strips a key that every\n * declaration agrees is unpersisted. A key added to one map alone is not\n * silently stripped from the other collection's writes — the spec's drift\n * assertion fires instead, and someone decides.\n */\n/*\n * Resolved on first use, not at module scope. Computing it eagerly made this\n * module — and therefore every consumer of the write callbacks — fail to LOAD\n * in any suite that mocks the `@aglyn/aglyn` barrel without re-exporting both\n * maps, which two console specs do. A write-boundary guard that cannot be\n * imported is worse than the leak it prevents.\n */\nlet unpersistedFields: readonly string[] | undefined\n\nfunction getUnpersistedFields(): readonly string[] {\n if (unpersistedFields) return unpersistedFields\n const host = HOST_UNPERSISTED_FIELDS ?? {}\n const org = ORG_UNPERSISTED_FIELDS ?? {}\n unpersistedFields = Object.freeze(\n Object.keys(host).filter((key) => key in org),\n )\n return unpersistedFields\n}\n\n/**\n * Drop the reader-injected keys before the payload leaves for Firestore.\n *\n * This has to happen here rather than in the refs' converters, which is where\n * it used to live alone (`503f197ca` added the `$id` strip to `useHostRef`'s\n * `toFirestore`). `updateDoc` never applies a converter — `setDoc` calls\n * `applyFirestoreDataConverter` and `updateDoc` does not — so a converter\n * strip is a guarantee about ONE of the two SDK calls, and which one a write\n * gets is decided by whether its caller happened to pass `merge`. That is how\n * `hosts/-MtN17_cpfPPLwWjE6z4` came to hold a persisted `$id` (AGL-1423).\n *\n * Only exact top-level keys are removed. Dotted `updateDoc` paths address\n * nested maps, and besigner node maps legitimately store a `$id` per node —\n * matching those would be the same over-broad scan AGL-1423 warned about.\n */\nfunction dropUnpersistedFields<D extends object>(data: D): D {\n const keys = getUnpersistedFields()\n if (!keys.some((key) => key in data)) return data\n const rest = { ...data } as Record<string, unknown>\n for (const key of keys) delete rest[key]\n return rest as D\n}\n\n\nexport type UpdateDocCallback<T> = (data: UpdateData<T>) => Promise<void>\nexport type SetDocCallback<T> = (\n data: Partial<T>,\n options?: SetOptions,\n) => Promise<void>\nexport type ModifyDocOptions = SetOptions & { shouldSet?: boolean }\nexport type ModifyDocCallback<T> = (\n data: UpdateData<T> | Partial<T>,\n options?: ModifyDocOptions,\n) => Promise<void>\n\n// `updateDoc`'s overload resolution keys off `DocumentReference`'s\n// `DbModelType` param, which defaults to the generic `DocumentData` when a\n// caller only supplies `AppModelType` (as every hook here does) — that\n// makes TS widen `UpdateData<T>` for unconstrained `T` and reject the\n// match. Pin the call to the single-overload signature actually used.\nconst typedUpdateDoc = updateDoc as <T>(\n ref: DocumentReference<T>,\n data: UpdateData<T>,\n) => Promise<void>\n\nexport function useUpdateDocCallback<T>(\n ref: DocumentReference<T>,\n): UpdateDocCallback<T> {\n return useCallback(\n (data: UpdateData<T>) =>\n typedUpdateDoc(\n ref,\n stampUpdatedAt(dropUnpersistedFields(data as object)) as UpdateData<T>,\n ),\n [ref],\n )\n}\n\nexport function useSetDocCallback<T>(\n ref: DocumentReference<T>,\n): SetDocCallback<T> {\n return useCallback(\n (data: Partial<T>, options?: SetOptions) =>\n setDoc(\n ref,\n stampUpdatedAt(dropUnpersistedFields(data as object)) as Partial<T>,\n options ?? {},\n ),\n [ref],\n )\n}\n\nexport function useModifyDocCallback<T>(\n ref: DocumentReference<T>,\n): ModifyDocCallback<T> {\n const updateDocCb = useUpdateDocCallback(ref)\n const setDocCb = useSetDocCallback(ref)\n return useCallback(\n (data: UpdateData<T> | Partial<T>, options?: ModifyDocOptions) => {\n // SetOptions semantics (merge/mergeFields) require setDoc: updateDoc\n // ignores them and, critically, bypasses the ref's withConverter\n // serialization (e.g. screen-version node compression) — and with it\n // EVERY strip the converter performs, including `$id` (AGL-1429).\n //\n // Nothing downstream of this line may therefore be relied on to clean\n // a payload. Anything that must hold for both branches belongs above\n // it, which is where `dropUnpersistedFields` now runs.\n const shouldSet =\n options?.shouldSet ||\n (options && 'merge' in options) ||\n (options && 'mergeFields' in options)\n if (shouldSet) return setDocCb(data as Partial<T>, options)\n return updateDocCb(data as UpdateData<T>)\n },\n [updateDocCb, setDocCb],\n )\n}\n\n\nexport type UseModifyDocCallback<T> = typeof useModifyDocCallback<T>\n\nexport default useModifyDocCallback\n"],"names":["HOST_UNPERSISTED_FIELDS","ORG_UNPERSISTED_FIELDS","serverTimestamp","setDoc","updateDoc","useCallback","stampUpdatedAt","data","updatedAt","unpersistedFields","getUnpersistedFields","host","org","Object","freeze","keys","filter","key","dropUnpersistedFields","some","rest","typedUpdateDoc","useUpdateDocCallback","ref","useSetDocCallback","options","useModifyDocCallback","updateDocCb","setDocCb","shouldSet"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,uBAAuB,EAAEC,sBAAsB,QAAQ,eAAc;AAC9E,SAEEC,eAAe,EACfC,MAAM,EAGNC,SAAS,QACJ,qBAAoB;AAC3B,SAASC,WAAW,QAAQ,QAAO;AAEnC;;;;CAIC,GACD,SAASC,eAAiCC,IAAO;IAC/C,OAAO;QAAEC,WAAWN;OAAsBK;AAC5C;AAEA;;;;;;;;CAQC,GACD;;;;;;CAMC,GACD,IAAIE;AAEJ,SAASC;IACP,IAAID,mBAAmB,OAAOA;IAC9B,MAAME,OAAOX,kCAAAA,0BAA2B,CAAC;IACzC,MAAMY,MAAMX,iCAAAA,yBAA0B,CAAC;IACvCQ,oBAAoBI,OAAOC,MAAM,CAC/BD,OAAOE,IAAI,CAACJ,MAAMK,MAAM,CAAC,CAACC,MAAQA,OAAOL;IAE3C,OAAOH;AACT;AAEA;;;;;;;;;;;;;;CAcC,GACD,SAASS,sBAAwCX,IAAO;IACtD,MAAMQ,OAAOL;IACb,IAAI,CAACK,KAAKI,IAAI,CAAC,CAACF,MAAQA,OAAOV,OAAO,OAAOA;IAC7C,MAAMa,OAAO,aAAKb;IAClB,KAAK,MAAMU,OAAOF,KAAM,OAAOK,IAAI,CAACH,IAAI;IACxC,OAAOG;AACT;AAcA,mEAAmE;AACnE,2EAA2E;AAC3E,uEAAuE;AACvE,sEAAsE;AACtE,sEAAsE;AACtE,MAAMC,iBAAiBjB;AAKvB,OAAO,SAASkB,qBACdC,GAAyB;IAEzB,OAAOlB,YACL,CAACE,OACCc,eACEE,KACAjB,eAAeY,sBAAsBX,SAEzC;QAACgB;KAAI;AAET;AAEA,OAAO,SAASC,kBACdD,GAAyB;IAEzB,OAAOlB,YACL,CAACE,MAAkBkB,UACjBtB,OACEoB,KACAjB,eAAeY,sBAAsBX,QACrCkB,kBAAAA,UAAW,CAAC,IAEhB;QAACF;KAAI;AAET;AAEA,OAAO,SAASG,qBACdH,GAAyB;IAEzB,MAAMI,cAAcL,qBAAqBC;IACzC,MAAMK,WAAWJ,kBAAkBD;IACnC,OAAOlB,YACL,CAACE,MAAkCkB;QACjC,qEAAqE;QACrE,iEAAiE;QACjE,qEAAqE;QACrE,kEAAkE;QAClE,EAAE;QACF,sEAAsE;QACtE,qEAAqE;QACrE,uDAAuD;QACvD,MAAMI,YACJJ,CAAAA,2BAAAA,QAASI,SAAS,KACjBJ,WAAW,WAAWA,WACtBA,WAAW,iBAAiBA;QAC/B,IAAII,WAAW,OAAOD,SAASrB,MAAoBkB;QACnD,OAAOE,YAAYpB;IACrB,GACA;QAACoB;QAAaC;KAAS;AAE3B;AAKA,eAAeF,qBAAoB"}
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/helpers/use-modify-doc-callback.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HOST_UNPERSISTED_FIELDS, ORG_UNPERSISTED_FIELDS } from '@aglyn/aglyn'\nimport {\n type DocumentData,\n type DocumentReference,\n type Firestore,\n serverTimestamp,\n setDoc,\n type SetOptions,\n type UpdateData,\n updateDoc,\n type WriteBatch,\n writeBatch,\n} from 'firebase/firestore'\nimport { useCallback } from 'react'\n\n/**\n * Every UI write stamps `updatedAt` (AGL-455) — the \"last updated\" columns\n * read it, and callers historically forgot it. Spread order lets a caller's\n * explicit `updatedAt` win (backfills, imports).\n */\nfunction stampUpdatedAt<D extends object>(data: D): D {\n return { updatedAt: serverTimestamp(), ...data }\n}\n\n/**\n * Keys that are never document fields, taken from where that is written down\n * (AGL-1429). `HOST_UNPERSISTED_FIELDS` and `ORG_UNPERSISTED_FIELDS` each\n * declare the synthetic `$id` the readers attach; the INTERSECTION is used\n * deliberately, so this generic boundary only ever strips a key that every\n * declaration agrees is unpersisted. A key added to one map alone is not\n * silently stripped from the other collection's writes — the spec's drift\n * assertion fires instead, and someone decides.\n */\n/*\n * Resolved on first use, not at module scope. Computing it eagerly made this\n * module — and therefore every consumer of the write callbacks — fail to LOAD\n * in any suite that mocks the `@aglyn/aglyn` barrel without re-exporting both\n * maps, which two console specs do. A write-boundary guard that cannot be\n * imported is worse than the leak it prevents.\n */\nlet unpersistedFields: readonly string[] | undefined\n\nfunction getUnpersistedFields(): readonly string[] {\n if (unpersistedFields) return unpersistedFields\n const host = HOST_UNPERSISTED_FIELDS ?? {}\n const org = ORG_UNPERSISTED_FIELDS ?? {}\n unpersistedFields = Object.freeze(\n Object.keys(host).filter((key) => key in org),\n )\n return unpersistedFields\n}\n\n/**\n * Drop the reader-injected keys before the payload leaves for Firestore.\n *\n * This has to happen here rather than in the refs' converters, which is where\n * it used to live alone (`503f197ca` added the `$id` strip to `useHostRef`'s\n * `toFirestore`). `updateDoc` never applies a converter — `setDoc` calls\n * `applyFirestoreDataConverter` and `updateDoc` does not — so a converter\n * strip is a guarantee about ONE of the two SDK calls, and which one a write\n * gets is decided by whether its caller happened to pass `merge`. That is how\n * `hosts/-MtN17_cpfPPLwWjE6z4` came to hold a persisted `$id` (AGL-1423).\n *\n * Only exact top-level keys are removed. Dotted `updateDoc` paths address\n * nested maps, and besigner node maps legitimately store a `$id` per node —\n * matching those would be the same over-broad scan AGL-1423 warned about.\n */\nfunction dropUnpersistedFields<D extends object>(data: D): D {\n const keys = getUnpersistedFields()\n if (!keys.some((key) => key in data)) return data\n const rest = { ...data } as Record<string, unknown>\n for (const key of keys) delete rest[key]\n return rest as D\n}\n\n\nexport type UpdateDocCallback<T> = (data: UpdateData<T>) => Promise<void>\nexport type SetDocCallback<T> = (\n data: Partial<T>,\n options?: SetOptions,\n) => Promise<void>\nexport type ModifyDocOptions = SetOptions & {\n shouldSet?: boolean\n /**\n * Other writes that must land in the SAME commit as this one, or not at\n * all. Given, the write goes through a batch — the same stamp, strip and\n * converter as without it — with whatever this stages beside it. The\n * console uses it to write a site-wide cache drop down durably next to a\n * settings save (AGL-3386).\n */\n alongside?: (batch: WriteBatch, firestore: Firestore) => void\n}\nexport type ModifyDocCallback<T> = (\n data: UpdateData<T> | Partial<T>,\n options?: ModifyDocOptions,\n) => Promise<void>\n\n// `updateDoc`'s overload resolution keys off `DocumentReference`'s\n// `DbModelType` param, which defaults to the generic `DocumentData` when a\n// caller only supplies `AppModelType` (as every hook here does) — that\n// makes TS widen `UpdateData<T>` for unconstrained `T` and reject the\n// match. Pin the call to the single-overload signature actually used.\nconst typedUpdateDoc = updateDoc as <T>(\n ref: DocumentReference<T>,\n data: UpdateData<T>,\n) => Promise<void>\n\nexport function useUpdateDocCallback<T>(\n ref: DocumentReference<T>,\n): UpdateDocCallback<T> {\n return useCallback(\n (data: UpdateData<T>) =>\n typedUpdateDoc(\n ref,\n stampUpdatedAt(dropUnpersistedFields(data as object)) as UpdateData<T>,\n ),\n [ref],\n )\n}\n\nexport function useSetDocCallback<T>(\n ref: DocumentReference<T>,\n): SetDocCallback<T> {\n return useCallback(\n (data: Partial<T>, options?: SetOptions) =>\n setDoc(\n ref,\n stampUpdatedAt(dropUnpersistedFields(data as object)) as Partial<T>,\n options ?? {},\n ),\n [ref],\n )\n}\n\nexport function useModifyDocCallback<T>(\n ref: DocumentReference<T>,\n): ModifyDocCallback<T> {\n const updateDocCb = useUpdateDocCallback(ref)\n const setDocCb = useSetDocCallback(ref)\n return useCallback(\n (data: UpdateData<T> | Partial<T>, options?: ModifyDocOptions) => {\n // SetOptions semantics (merge/mergeFields) require setDoc: updateDoc\n // ignores them and, critically, bypasses the ref's withConverter\n // serialization (e.g. screen-version node compression) — and with it\n // EVERY strip the converter performs, including `$id` (AGL-1429).\n //\n // Nothing downstream of this line may therefore be relied on to clean\n // a payload. Anything that must hold for both branches belongs above\n // it, which is where `dropUnpersistedFields` now runs.\n const shouldSet =\n options?.shouldSet ||\n (options && 'merge' in options) ||\n (options && 'mergeFields' in options)\n if (options?.alongside) {\n // `shouldSet` is this hook's own flag, never a Firestore option.\n const { alongside, shouldSet: _flag, ...setOptions } = options\n void _flag\n const batch = writeBatch(ref.firestore)\n const payload = stampUpdatedAt(dropUnpersistedFields(data as object))\n // `batch.set` applies the ref's converter exactly as `setDoc` does,\n // and `batch.update` skips it exactly as `updateDoc` does — so the\n // batched write stores what the unbatched one would have.\n if (shouldSet) batch.set(ref, payload as Partial<T>, setOptions)\n // Pinned to the untyped overload for the reason `typedUpdateDoc` is.\n else batch.update(ref as DocumentReference, payload as UpdateData<DocumentData>)\n alongside(batch, ref.firestore)\n return batch.commit()\n }\n if (shouldSet) return setDocCb(data as Partial<T>, options)\n return updateDocCb(data as UpdateData<T>)\n },\n [ref, updateDocCb, setDocCb],\n )\n}\n\n\nexport type UseModifyDocCallback<T> = typeof useModifyDocCallback<T>\n\nexport default useModifyDocCallback\n"],"names":["HOST_UNPERSISTED_FIELDS","ORG_UNPERSISTED_FIELDS","serverTimestamp","setDoc","updateDoc","writeBatch","useCallback","stampUpdatedAt","data","updatedAt","unpersistedFields","getUnpersistedFields","host","org","Object","freeze","keys","filter","key","dropUnpersistedFields","some","rest","typedUpdateDoc","useUpdateDocCallback","ref","useSetDocCallback","options","useModifyDocCallback","updateDocCb","setDocCb","shouldSet","alongside","_flag","setOptions","batch","firestore","payload","set","update","commit"],"mappings":";;AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,uBAAuB,EAAEC,sBAAsB,QAAQ,eAAc;AAC9E,SAIEC,eAAe,EACfC,MAAM,EAGNC,SAAS,EAETC,UAAU,QACL,qBAAoB;AAC3B,SAASC,WAAW,QAAQ,QAAO;AAEnC;;;;CAIC,GACD,SAASC,eAAiCC,IAAO;IAC/C,OAAO;QAAEC,WAAWP;OAAsBM;AAC5C;AAEA;;;;;;;;CAQC,GACD;;;;;;CAMC,GACD,IAAIE;AAEJ,SAASC;IACP,IAAID,mBAAmB,OAAOA;IAC9B,MAAME,OAAOZ,kCAAAA,0BAA2B,CAAC;IACzC,MAAMa,MAAMZ,iCAAAA,yBAA0B,CAAC;IACvCS,oBAAoBI,OAAOC,MAAM,CAC/BD,OAAOE,IAAI,CAACJ,MAAMK,MAAM,CAAC,CAACC,MAAQA,OAAOL;IAE3C,OAAOH;AACT;AAEA;;;;;;;;;;;;;;CAcC,GACD,SAASS,sBAAwCX,IAAO;IACtD,MAAMQ,OAAOL;IACb,IAAI,CAACK,KAAKI,IAAI,CAAC,CAACF,MAAQA,OAAOV,OAAO,OAAOA;IAC7C,MAAMa,OAAO,aAAKb;IAClB,KAAK,MAAMU,OAAOF,KAAM,OAAOK,IAAI,CAACH,IAAI;IACxC,OAAOG;AACT;AAwBA,mEAAmE;AACnE,2EAA2E;AAC3E,uEAAuE;AACvE,sEAAsE;AACtE,sEAAsE;AACtE,MAAMC,iBAAiBlB;AAKvB,OAAO,SAASmB,qBACdC,GAAyB;IAEzB,OAAOlB,YACL,CAACE,OACCc,eACEE,KACAjB,eAAeY,sBAAsBX,SAEzC;QAACgB;KAAI;AAET;AAEA,OAAO,SAASC,kBACdD,GAAyB;IAEzB,OAAOlB,YACL,CAACE,MAAkBkB,UACjBvB,OACEqB,KACAjB,eAAeY,sBAAsBX,QACrCkB,kBAAAA,UAAW,CAAC,IAEhB;QAACF;KAAI;AAET;AAEA,OAAO,SAASG,qBACdH,GAAyB;IAEzB,MAAMI,cAAcL,qBAAqBC;IACzC,MAAMK,WAAWJ,kBAAkBD;IACnC,OAAOlB,YACL,CAACE,MAAkCkB;QACjC,qEAAqE;QACrE,iEAAiE;QACjE,qEAAqE;QACrE,kEAAkE;QAClE,EAAE;QACF,sEAAsE;QACtE,qEAAqE;QACrE,uDAAuD;QACvD,MAAMI,YACJJ,CAAAA,2BAAAA,QAASI,SAAS,KACjBJ,WAAW,WAAWA,WACtBA,WAAW,iBAAiBA;QAC/B,IAAIA,2BAAAA,QAASK,SAAS,EAAE;YACtB,iEAAiE;YACjE,MAAM,EAAEA,SAAS,EAAED,WAAWE,KAAK,EAAiB,GAAGN,SAAfO,8CAAeP;;;;YACvD,KAAKM;YACL,MAAME,QAAQ7B,WAAWmB,IAAIW,SAAS;YACtC,MAAMC,UAAU7B,eAAeY,sBAAsBX;YACrD,oEAAoE;YACpE,mEAAmE;YACnE,0DAA0D;YAC1D,IAAIsB,WAAWI,MAAMG,GAAG,CAACb,KAAKY,SAAuBH;iBAEhDC,MAAMI,MAAM,CAACd,KAA0BY;YAC5CL,UAAUG,OAAOV,IAAIW,SAAS;YAC9B,OAAOD,MAAMK,MAAM;QACrB;QACA,IAAIT,WAAW,OAAOD,SAASrB,MAAoBkB;QACnD,OAAOE,YAAYpB;IACrB,GACA;QAACgB;QAAKI;QAAaC;KAAS;AAEhC;AAKA,eAAeF,qBAAoB"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * A stand-in for `hooks/helpers/site-wide-change` in a card's spec (AGL-3386).
19
+ *
20
+ * A card that saves something a published page renders writes through
21
+ * `writeSiteWideChange`, which batches the write with a publish outbox entry
22
+ * and asks the console to drop the site's cache. A card's spec is about the
23
+ * write the card makes, and it already doubles `firebase/firestore`'s
24
+ * `setDoc`/`updateDoc`/`deleteDoc` to see it. This routes each staged batch
25
+ * operation to the SAME double call the card made before it batched — so a
26
+ * spec keeps asserting what reached storage, and says nothing about a cache
27
+ * drop it is not testing. The helper's own contract is pinned in
28
+ * `site-wide-change.spec.ts`.
29
+ *
30
+ * Use it as the factory of the module mock:
31
+ *
32
+ * jest.mock('@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change', () =>
33
+ * jest
34
+ * .requireActual('@aglyn/tenant-feature-instance/testing/site-wide-change-double')
35
+ * .siteWideChangeThroughSdk(() => jest.requireMock('firebase/firestore')),
36
+ * )
37
+ *
38
+ * The SDK is handed in rather than required here: Jest injects `jest` into
39
+ * each spec module rather than onto `globalThis`, so this file cannot reach
40
+ * the spec's mock itself.
41
+ */
42
+ type SdkCall = (...args: unknown[]) => Promise<unknown>;
43
+ interface SdkWrites {
44
+ setDoc: SdkCall;
45
+ updateDoc: SdkCall;
46
+ deleteDoc: SdkCall;
47
+ }
48
+ /** The module's exports, with every write sent to the doubled SDK calls. */
49
+ export declare function siteWideChangeThroughSdk(sdk: () => SdkWrites): {
50
+ __esModule: boolean;
51
+ SITE_WIDE_OUTBOX_COLLECTION: string;
52
+ SITE_WIDE_OUTBOX_PATHS: string[];
53
+ writeSiteWideChange: (options: {
54
+ write: (batch: ReturnType<(pending: Promise<unknown>[]) => {
55
+ set: (...args: unknown[]) => void;
56
+ update: (...args: unknown[]) => void;
57
+ delete: (...args: unknown[]) => void;
58
+ }>) => void;
59
+ }) => Promise<void>;
60
+ commitWithSiteWideEntry: (_hostId: string, commit: (stage: undefined) => Promise<void>) => Promise<any>;
61
+ settleSiteWideChange: () => any;
62
+ announceSiteWideChange: () => Promise<any>;
63
+ releaseSiteWideOutboxEntry: () => Promise<any>;
64
+ stageSiteWideOutboxEntry: () => any;
65
+ };
66
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * A stand-in for `hooks/helpers/site-wide-change` in a card's spec (AGL-3386).
18
+ *
19
+ * A card that saves something a published page renders writes through
20
+ * `writeSiteWideChange`, which batches the write with a publish outbox entry
21
+ * and asks the console to drop the site's cache. A card's spec is about the
22
+ * write the card makes, and it already doubles `firebase/firestore`'s
23
+ * `setDoc`/`updateDoc`/`deleteDoc` to see it. This routes each staged batch
24
+ * operation to the SAME double call the card made before it batched — so a
25
+ * spec keeps asserting what reached storage, and says nothing about a cache
26
+ * drop it is not testing. The helper's own contract is pinned in
27
+ * `site-wide-change.spec.ts`.
28
+ *
29
+ * Use it as the factory of the module mock:
30
+ *
31
+ * jest.mock('@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change', () =>
32
+ * jest
33
+ * .requireActual('@aglyn/tenant-feature-instance/testing/site-wide-change-double')
34
+ * .siteWideChangeThroughSdk(() => jest.requireMock('firebase/firestore')),
35
+ * )
36
+ *
37
+ * The SDK is handed in rather than required here: Jest injects `jest` into
38
+ * each spec module rather than onto `globalThis`, so this file cannot reach
39
+ * the spec's mock itself.
40
+ */ /** The module's exports, with every write sent to the doubled SDK calls. */ export function siteWideChangeThroughSdk(sdk) {
41
+ const batchOverSdk = (pending)=>({
42
+ set: (...args)=>{
43
+ pending.push(sdk().setDoc(...args));
44
+ },
45
+ update: (...args)=>{
46
+ pending.push(sdk().updateDoc(...args));
47
+ },
48
+ delete: (...args)=>{
49
+ pending.push(sdk().deleteDoc(...args));
50
+ }
51
+ });
52
+ return {
53
+ __esModule: true,
54
+ SITE_WIDE_OUTBOX_COLLECTION: 'publishOutbox',
55
+ SITE_WIDE_OUTBOX_PATHS: [
56
+ '/'
57
+ ],
58
+ writeSiteWideChange: async (options)=>{
59
+ const pending = [];
60
+ options.write(batchOverSdk(pending));
61
+ await Promise.all(pending);
62
+ },
63
+ commitWithSiteWideEntry: async (_hostId, commit)=>{
64
+ await commit(undefined);
65
+ return null;
66
+ },
67
+ settleSiteWideChange: ()=>undefined,
68
+ announceSiteWideChange: async ()=>null,
69
+ releaseSiteWideOutboxEntry: async ()=>undefined,
70
+ stageSiteWideOutboxEntry: ()=>null
71
+ };
72
+ }
73
+
74
+ //# sourceMappingURL=site-wide-change-double.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/testing/site-wide-change-double.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A stand-in for `hooks/helpers/site-wide-change` in a card's spec (AGL-3386).\n *\n * A card that saves something a published page renders writes through\n * `writeSiteWideChange`, which batches the write with a publish outbox entry\n * and asks the console to drop the site's cache. A card's spec is about the\n * write the card makes, and it already doubles `firebase/firestore`'s\n * `setDoc`/`updateDoc`/`deleteDoc` to see it. This routes each staged batch\n * operation to the SAME double call the card made before it batched — so a\n * spec keeps asserting what reached storage, and says nothing about a cache\n * drop it is not testing. The helper's own contract is pinned in\n * `site-wide-change.spec.ts`.\n *\n * Use it as the factory of the module mock:\n *\n * jest.mock('@aglyn/tenant-feature-instance/hooks/helpers/site-wide-change', () =>\n * jest\n * .requireActual('@aglyn/tenant-feature-instance/testing/site-wide-change-double')\n * .siteWideChangeThroughSdk(() => jest.requireMock('firebase/firestore')),\n * )\n *\n * The SDK is handed in rather than required here: Jest injects `jest` into\n * each spec module rather than onto `globalThis`, so this file cannot reach\n * the spec's mock itself.\n */\n\ntype SdkCall = (...args: unknown[]) => Promise<unknown>\n\ninterface SdkWrites {\n setDoc: SdkCall\n updateDoc: SdkCall\n deleteDoc: SdkCall\n}\n\n/** The module's exports, with every write sent to the doubled SDK calls. */\nexport function siteWideChangeThroughSdk(sdk: () => SdkWrites) {\n const batchOverSdk = (pending: Promise<unknown>[]) => ({\n set: (...args: unknown[]) => {\n pending.push(sdk().setDoc(...args))\n },\n update: (...args: unknown[]) => {\n pending.push(sdk().updateDoc(...args))\n },\n delete: (...args: unknown[]) => {\n pending.push(sdk().deleteDoc(...args))\n },\n })\n return {\n __esModule: true,\n SITE_WIDE_OUTBOX_COLLECTION: 'publishOutbox',\n SITE_WIDE_OUTBOX_PATHS: ['/'],\n writeSiteWideChange: async (options: {\n write: (batch: ReturnType<typeof batchOverSdk>) => void\n }) => {\n const pending: Promise<unknown>[] = []\n options.write(batchOverSdk(pending))\n await Promise.all(pending)\n },\n commitWithSiteWideEntry: async (\n _hostId: string,\n commit: (stage: undefined) => Promise<void>,\n ) => {\n await commit(undefined)\n return null\n },\n settleSiteWideChange: () => undefined,\n announceSiteWideChange: async () => null,\n releaseSiteWideOutboxEntry: async () => undefined,\n stageSiteWideOutboxEntry: () => null,\n }\n}\n"],"names":["siteWideChangeThroughSdk","sdk","batchOverSdk","pending","set","args","push","setDoc","update","updateDoc","delete","deleteDoc","__esModule","SITE_WIDE_OUTBOX_COLLECTION","SITE_WIDE_OUTBOX_PATHS","writeSiteWideChange","options","write","Promise","all","commitWithSiteWideEntry","_hostId","commit","undefined","settleSiteWideChange","announceSiteWideChange","releaseSiteWideOutboxEntry","stageSiteWideOutboxEntry"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GAUD,0EAA0E,GAC1E,OAAO,SAASA,yBAAyBC,GAAoB;IAC3D,MAAMC,eAAe,CAACC,UAAiC,CAAA;YACrDC,KAAK,CAAC,GAAGC;gBACPF,QAAQG,IAAI,CAACL,MAAMM,MAAM,IAAIF;YAC/B;YACAG,QAAQ,CAAC,GAAGH;gBACVF,QAAQG,IAAI,CAACL,MAAMQ,SAAS,IAAIJ;YAClC;YACAK,QAAQ,CAAC,GAAGL;gBACVF,QAAQG,IAAI,CAACL,MAAMU,SAAS,IAAIN;YAClC;QACF,CAAA;IACA,OAAO;QACLO,YAAY;QACZC,6BAA6B;QAC7BC,wBAAwB;YAAC;SAAI;QAC7BC,qBAAqB,OAAOC;YAG1B,MAAMb,UAA8B,EAAE;YACtCa,QAAQC,KAAK,CAACf,aAAaC;YAC3B,MAAMe,QAAQC,GAAG,CAAChB;QACpB;QACAiB,yBAAyB,OACvBC,SACAC;YAEA,MAAMA,OAAOC;YACb,OAAO;QACT;QACAC,sBAAsB,IAAMD;QAC5BE,wBAAwB,UAAY;QACpCC,4BAA4B,UAAYH;QACxCI,0BAA0B,IAAM;IAClC;AACF"}