uniweb 0.64.1 → 0.66.0

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.
@@ -15,10 +15,12 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from '
15
15
  import { join, dirname, relative, isAbsolute } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
+ import { recordWritten } from '../utils/pull-written.js'
18
19
  import { humanBytes } from '../utils/bytes.js'
19
20
  import { isAuthoredPreview } from '../utils/preview.js'
20
21
  import {
21
22
  describeRequestError,
23
+ describeWorkspace,
22
24
  WorkspaceMismatchError,
23
25
  workspaceHandle,
24
26
  workspaceHeader
@@ -32,6 +34,7 @@ import {
32
34
  diffSiteUnits,
33
35
  describeSiteDiff,
34
36
  computeUnitHashes,
37
+ collectSiteUnits,
35
38
  collectUnitUuids,
36
39
  collectFolderItemUuids,
37
40
  collectQueryUuids,
@@ -90,6 +93,141 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
90
93
  }
91
94
  }
92
95
 
96
+ // A unit in the form it is compared in across the two representations: keys sorted,
97
+ // `$`-keys dropped (`$uuid`, `$id` — identity and payload handles, not content).
98
+ const canonicalUnit = (v) =>
99
+ Array.isArray(v)
100
+ ? v.map(canonicalUnit)
101
+ : v && typeof v === 'object'
102
+ ? Object.fromEntries(
103
+ Object.keys(v)
104
+ .filter((k) => !k.startsWith('$'))
105
+ .sort()
106
+ .map((k) => [k, canonicalUnit(v[k])])
107
+ )
108
+ : v
109
+
110
+ /**
111
+ * Whether the backend's post-write copy of a unit is the content we sent.
112
+ *
113
+ * Compared on OUR keys only. The two are not byte-comparable — the backend's copy
114
+ * carries fields we don't emit (`params`, `theme_override`, its own `$uuid`) and
115
+ * orders keys its own way, which is why `site-diff.js` never hashes one side against
116
+ * the other — but what we emit, it stores verbatim: measured 2026-09-23 against a
117
+ * live `uniwebd`, 15 of 15 units equal on our keys after a push.
118
+ */
119
+ function writtenAsSent(sent, written) {
120
+ if (!sent || !written) return false
121
+ const onOurKeys = {}
122
+ for (const k of Object.keys(sent)) if (!k.startsWith('$')) onOurKeys[k] = written[k]
123
+ return JSON.stringify(canonicalUnit(sent)) === JSON.stringify(canonicalUnit(onOurKeys))
124
+ }
125
+
126
+ /**
127
+ * The post-write tokens a site-content push may bank: only those that describe
128
+ * content this copy HOLDS.
129
+ *
130
+ * ⛔ BANKING EVERY RETURNED TOKEN LOST OTHER WRITERS' WORK — measured 2026-09-23 on a
131
+ * live `uniwebd`, two copies of one site:
132
+ *
133
+ * 1. B edits section X and pushes.
134
+ * 2. A, which has not pulled, pushes an edit to section Y. The per-item gate reads
135
+ * A's X as untouched (`pkg == base`) and keeps B's X — correct. But the response
136
+ * carries X's token for B's content, and A banked it.
137
+ * 3. A's next push of X then reads as "only you changed it" (`host == base`) and
138
+ * overwrites B's edit, with no refusal.
139
+ *
140
+ * The entity token did the same to ADDITIONS: a section B added survived A's first
141
+ * push (created after A's base ⇒ KEEP), A banked a base newer than it, and A's next
142
+ * push deleted it (created at-or-before the base ⇒ "you dropped it").
143
+ *
144
+ * So a unit's token is banked only when its post-write copy is what we sent — our own
145
+ * write, or a unit nobody touched. A unit the backend kept for someone else keeps the
146
+ * token we already hold, which is the one for OUR content, so the next push that
147
+ * changes it is `pkg != base, host != base` and refused. And while the backend holds
148
+ * units we don't, the entity token stays where it was, so the next push keeps them.
149
+ *
150
+ * With either document missing there is nothing to compare, and the tokens are banked
151
+ * as the backend sent them — the contract is that `finalized[].document` is always
152
+ * there.
153
+ *
154
+ * @returns {{ version: string|null, itemVersions: Object<string,string>|null,
155
+ * held: string[], kept: string[], foreign: string[] }} `held`: units the backend
156
+ * holds as we sent them; `kept`: units it kept someone else's content for;
157
+ * `foreign`: units it holds that we did not send. All paths.
158
+ */
159
+ export function heldTokens({ sent, written, version = null, itemVersions = null }) {
160
+ if (!sent || !written) return { version, itemVersions, held: [], kept: [], foreign: [] }
161
+ const ours = collectSiteUnits(sent)
162
+ const theirs = collectSiteUnits(written)
163
+ const uuidAt = collectUnitUuids(written)
164
+ const units = new Set(Object.values(uuidAt))
165
+ const held = []
166
+ const kept = []
167
+ const foreign = []
168
+ const items = {}
169
+ for (const [path, uuid] of Object.entries(uuidAt)) {
170
+ if (!ours.has(path)) {
171
+ foreign.push(path)
172
+ continue
173
+ }
174
+ if (!writtenAsSent(ours.get(path), theirs.get(path))) {
175
+ kept.push(path)
176
+ continue
177
+ }
178
+ held.push(path)
179
+ if (itemVersions?.[uuid]) items[uuid] = itemVersions[uuid]
180
+ }
181
+ // A token for an item that is not a unit (a `queries` declaration) is never sent
182
+ // back — the emit narrows preconditions to units (`withBaseVersion`) — so it is
183
+ // banked as before: it can neither arm nor disarm anything.
184
+ for (const [uuid, token] of Object.entries(itemVersions || {})) {
185
+ if (!units.has(uuid)) items[uuid] = token
186
+ }
187
+ return {
188
+ version: foreign.length ? null : version,
189
+ itemVersions: itemVersions ? items : null,
190
+ held: held.sort(),
191
+ kept: kept.sort(),
192
+ foreign: foreign.sort()
193
+ }
194
+ }
195
+
196
+ // The files a unit projects to, under the site's own roots — `site.yml::paths` can
197
+ // relocate `pages` and `layout`. `site.yml` stands for the `info` unit, which projects
198
+ // to three files.
199
+ function unitFilesOf(siteDir, unitPath) {
200
+ if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
201
+ let paths = {}
202
+ try {
203
+ paths = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
204
+ } catch {
205
+ /* no or unreadable site.yml — the defaults are right */
206
+ }
207
+ for (const root of ['pages', 'layout']) {
208
+ if (unitPath.startsWith(`${root}/`)) return [`${paths[root] || root}${unitPath.slice(root.length)}`]
209
+ }
210
+ return []
211
+ }
212
+
213
+ /**
214
+ * Record, as synced, the files of the units a push left on the backend as we sent them.
215
+ *
216
+ * ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
217
+ * version a file last synced at by the hash in `pull-written.json`. A pull records what
218
+ * it wrote; this records what a push delivered — the units `heldTokens` says the
219
+ * backend holds as ours, never one it kept for someone else. Without it, after a merge
220
+ * and a push the ancestor was the pull's raw output, which nobody commits: the next
221
+ * merge found no common version and put both whole files under conflict markers, for
222
+ * edits on different lines (measured 2026-09-23).
223
+ */
224
+ function recordPushedUnits(siteDir, unitPaths) {
225
+ const files = new Set()
226
+ for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
227
+ const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
228
+ if (present.length) recordWritten(siteDir, present)
229
+ }
230
+
93
231
  // Pull the finalized entities out of the restore response. The backend returns
94
232
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
95
233
  // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
@@ -111,10 +249,10 @@ export function extractFinalized(payload) {
111
249
  uuid: d?.uuid ?? d?.document?.$uuid ?? null,
112
250
  changed: d?.changed,
113
251
  // Post-write optimistic-concurrency token, read back from the stored row.
114
- // Cache it UNCONDITIONALLY — do not branch on `changed` and do not infer:
115
- // the backend pins "zero-write ⇒ version unmoved" with a test, so a no-op
116
- // resubmit returns the value we already hold, and anything else is theirs
117
- // to report, not ours to derive.
252
+ // Do not branch on `changed`: the backend pins "zero-write ⇒ version unmoved"
253
+ // with a test, so a no-op resubmit returns the value we already hold. ⛔ But a
254
+ // site-content push banks it only while the stored document holds nothing we
255
+ // don't — see `heldTokens`, and what banking it unconditionally deleted.
118
256
  version: typeof d?.version === 'string' ? d.version : null,
119
257
  // Per-item post-write tokens, `{ recordUuid: <opaque> }` (backend `d7e46335`).
120
258
  // Same map shape and same key name the PULL manifest entry stamps, deliberately
@@ -547,8 +685,12 @@ export function readItemBaseVersions(siteDir, backend) {
547
685
  }
548
686
  export function mergeItemBaseVersions(siteDir, backend, versions) {
549
687
  if (!versions || !Object.keys(versions).length) return
688
+ // ⛔ The read names the backend. Without it the read keyed nothing, came back `{}`,
689
+ // and this "merge" replaced the map with the last response — dropping the token
690
+ // `heldTokens` withholds for a unit the backend kept for someone else, and with it
691
+ // the precondition that refuses the next push of that unit (2026-09-23).
550
692
  updateSyncCache(siteDir, backend, {
551
- itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
693
+ itemBaseVersions: { ...readItemBaseVersions(siteDir, backend), ...versions }
552
694
  })
553
695
  }
554
696
  export function mergeBaseVersions(siteDir, backend, versions) {
@@ -652,21 +794,19 @@ export function writeItemUuids(siteDir, backend, map) {
652
794
  }
653
795
 
654
796
  /**
655
- * The workspace this project's requests name on `backend`, as the header names it —
656
- * `@handle`, or a unit's bare uuid when that unit has no handle — or null for none.
657
- *
658
- * Read back from `backend`'s section of `sync.json`: `site.org` holds a handle, stored
659
- * bare and re-dressed with its `@`; `site.unit` holds a unit's uuid, for a workspace
660
- * that has no handle. At most one is set (`writeSiteWorkspace`). ⛔ They are two keys
661
- * because the `@` is the only thing telling the two apart — our own handle grammar
662
- * admits a uuid-shaped handle — and a bare value at rest has lost it.
797
+ * The workspace this site was created in on `backend` — its owner, as the create
798
+ * answered it — in the header's form: `@handle`, a unit's bare uuid when that unit has
799
+ * no handle, or null for the personal workspace (or no record).
663
800
  *
664
- * Recorded at create — the site's owner — or adopted later from a backend's `409
665
- * wrong_workspace`, which may name a PARENT workspace that contains the site rather
666
- * than the site's own unit. ⇒ It is "the workspace these requests name", never
667
- * printed as "the site's org".
801
+ * ⭐ A RECORD, NOT A ROUTE (2026-09-23). A command works in the workspace chosen with the
802
+ * login (`workspace.js`); this says where the site was made, and routes nothing. (Until
803
+ * then it was the workspace every request named, adopted from a `409` when stale.)
668
804
  *
669
- * Deliberately NOT a fallback for the flag: an explicit `--org` always wins.
805
+ * Read from `backend`'s section of `sync.json`: `site.org` holds a handle, stored bare
806
+ * and re-dressed with its `@`; `site.unit` holds a unit's uuid. At most one is set
807
+ * (`writeSiteWorkspace`). ⛔ Two keys, because the `@` is the only thing telling the two
808
+ * apart — our own handle grammar admits a uuid-shaped handle — and a bare value at rest
809
+ * has lost it.
670
810
  *
671
811
  * @param {string} siteDir
672
812
  * @param {string} backend - the origin whose record to read
@@ -681,10 +821,10 @@ export function readSiteWorkspace(siteDir, backend) {
681
821
  }
682
822
 
683
823
  /**
684
- * Record the workspace this project's requests name on `backend` — a handle in
685
- * `site.org` (bare), a handle-less unit in `site.unit`, clearing the other; null
686
- * clears both (the personal workspace, named by naming none). Returns what was
687
- * recorded, in the header's form.
824
+ * Record the workspace this site was created in on `backend` — a handle in `site.org`
825
+ * (bare), a handle-less unit in `site.unit`, clearing the other; null clears both (the
826
+ * personal workspace, named by naming none). Returns what was recorded, in the header's
827
+ * form.
688
828
  *
689
829
  * @param {string} siteDir
690
830
  * @param {string} backend
@@ -702,50 +842,6 @@ function writeSiteWorkspace(siteDir, backend, workspace) {
702
842
  return w
703
843
  }
704
844
 
705
- /** A workspace as a sentence names it: `@acme`, `the unit <uuid>`, or `your personal workspace`. */
706
- export function describeWorkspace(workspace) {
707
- if (!workspace) return 'your personal workspace'
708
- return workspace.startsWith('@') ? workspace : `the unit ${workspace}`
709
- }
710
-
711
- /**
712
- * Point `client` at the workspace this project's requests name — `x-uniweb-workspace`.
713
- *
714
- * ⭐ THE WORKSPACE IS PER PROJECT, per backend — the one `sync.json` recorded — never
715
- * per login: a login is one session for the whole machine (`utils/session-file.js`).
716
- * An explicit choice (`--org`, `--personal`, the create picker) is sent as named, and
717
- * a `409 wrong_workspace` then STOPS the command. Otherwise the client adopts the
718
- * workspace the backend names, and this records it — as-is, since it may be a parent
719
- * workspace, or a unit with no handle — and says so. (`client.js::request`; agreed
720
- * with backend 2026-09-23.)
721
- *
722
- * @param {import('./client.js').BackendClient} client
723
- * @param {object} p
724
- * @param {string} p.siteDir
725
- * @param {string|null} p.workspace - in the header's form (`readSiteWorkspace`, or
726
- * `workspaceHandle(--org)`), or null to name none
727
- * @param {boolean} [p.explicit=false]
728
- * @param {(m: string) => void} [p.note]
729
- * @returns {import('./client.js').BackendClient}
730
- */
731
- export function nameSiteWorkspace(client, { siteDir, workspace, explicit = false, note }) {
732
- return client.setWorkspace(workspace, {
733
- explicit,
734
- onAdopted: (adopted) => {
735
- try {
736
- writeSiteWorkspace(siteDir, client.origin, adopted)
737
- } catch {
738
- // Recording is a convenience for the next command; this one still retries.
739
- }
740
- note?.(
741
- adopted
742
- ? `Requests for this site now name ${describeWorkspace(adopted)}, the workspace the backend works on it from (recorded in sync.json).`
743
- : 'Requests for this site now name no workspace — the backend works on it from your personal one (recorded in sync.json).'
744
- )
745
- }
746
- })
747
- }
748
-
749
845
  /**
750
846
  * Record the workspace a just-minted site was created under.
751
847
  *
@@ -768,173 +864,6 @@ function recordSiteWorkspace(siteDir, backend, owner) {
768
864
  }
769
865
  }
770
866
 
771
- /**
772
- * The `source`s of `resolveSiteOrgForCreate` that are the user's OWN choice — named on
773
- * the command line or picked — and so explicit: a `409 wrong_workspace` for one stops
774
- * the command rather than adopting the backend's answer (`nameSiteWorkspace`).
775
- */
776
- export const EXPLICIT_OWNER = new Set(['flag', 'personal', 'picked'])
777
-
778
- /**
779
- * Resolve WHICH ORG will own a site that is about to be created.
780
- *
781
- * The create that mints the site's uuid takes its owner from the workspace the request
782
- * names (`x-uniweb-workspace`), and the backend never moves ownership afterwards. There is also no CLI verb to transfer
783
- * or delete a site. So this is a **one-shot, unrepealable** decision — and until
784
- * this function existed the CLI made it silently, by sending nothing and letting
785
- * the backend fall back to the session's personal context. A developer who belongs
786
- * to several orgs could put a company site, and the storage it bills, somewhere
787
- * they never named.
788
- *
789
- * `register` resolves a foundation's scope in the same order — recorded (the scope
790
- * in its name, `@acme/marketing` in main.js), else explicit (`--scope`), else derived
791
- * (`deriveScope`) — with one difference: non-interactive, it takes your personal
792
- * scope and says so. This refuses instead, because a site's owner is the one-shot
793
- * decision above, and a scope is only a namespace (2026-09-23). ⭐ And the two are
794
- * separate decisions: a foundation may be registered under any org its author
795
- * belongs to, whoever owns the site (2026-09-22) — a site's `@/x` refs take the
796
- * FOUNDATION's scope, never this one. ⚠️ Until 2026-09-21 this said
797
- * `register` "has refused to guess a scope", which is wrong for a non-interactive
798
- * run by anyone with an org.
799
- *
800
- * Order:
801
- * 1. `--org @org` — explicit; the create names it as its workspace
802
- * 2. `--personal` — explicit "no org, I mean it" → names NO workspace
803
- * 3. the recorded org — `sync.json`, written at this site's own create
804
- * 4. the site already exists on this backend (a recorded uuid) → null; ownership
805
- * is settled, ask nothing
806
- * 5. otherwise ASK (TTY) or REFUSE (non-interactive)
807
- *
808
- * ⛔ **`--personal` names no workspace, and is NOT the same as `--org @<handle>`.**
809
- * The personal *org* `@jane` is an org like any other, lazily created on first use;
810
- * the session's personal workspace is not an org at all. Whether the backend gives
811
- * them the same owning unit is **its** business and unverified here, so the
812
- * deliberate-personal spelling names nothing rather than asserting an equivalence
813
- * this lane cannot check.
814
- *
815
- * @returns {Promise<{ workspace: string|null, source?: string, refused?: true, reason?: string }>}
816
- * `workspace: null` with no `refused` means "name no workspace" — either a settled site
817
- * or a deliberate personal choice. `source` says which answer it is: `flag`,
818
- * `personal` and `picked` are the user's own choice (explicit — a mismatch then
819
- * stops the command); `recorded`, `existing` and `offline` are not.
820
- */
821
- export async function resolveSiteOrgForCreate({
822
- client,
823
- siteDir,
824
- args = [],
825
- flag,
826
- personal = false,
827
- offline = false
828
- }) {
829
- if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
830
- if (personal) return { workspace: null, source: 'personal' }
831
-
832
- const recorded = readSiteWorkspace(siteDir, client?.origin)
833
- if (recorded) return { workspace: recorded, source: 'recorded' }
834
-
835
- // Already created ⇒ nothing to decide: ownership was settled at its create and
836
- // re-asking would be theatre. ⭐ "Created" is a question about THIS backend now —
837
- // the same project may be brand new on one and long-established on another.
838
- if (readBackendState(siteDir, client?.origin).site?.uuid) return { workspace: null, source: 'existing' }
839
-
840
- // An offline preview (`--dry-run` / `-o`) must never authenticate, and it is
841
- // creating nothing, so there is no decision to force. Say what is unresolved
842
- // instead of prompting for an answer the run will not use.
843
- if (offline) return { workspace: null, source: 'offline' }
844
-
845
- // `--yes` promises "never block on a prompt", so it has to answer this one too —
846
- // and the only honest non-blocking answer to an unanswerable ownership question
847
- // is a refusal. Treating it as consent-to-anything would reinstate the silent
848
- // default this whole path exists to remove, behind a flag that reads like
849
- // approval.
850
- const { isNonInteractive } = await import('../utils/interactive.js')
851
- if (isNonInteractive(args) || args.includes('--yes')) {
852
- return {
853
- workspace: null,
854
- refused: true,
855
- reason:
856
- 'This site does not exist on the backend yet, and no org was named.\n' +
857
- ' The create decides who OWNS it — and which workspace its storage is billed to —\n' +
858
- ' one time, with no CLI way to change it afterwards. Name it explicitly:\n' +
859
- ' --org @org create it under an organization\n' +
860
- ' --personal create it under your personal account, deliberately'
861
- }
862
- }
863
-
864
- // Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one picks
865
- // a NAMESPACE for a foundation, where `@<your handle>` is your personal scope. Here
866
- // the answer is an OWNER: your personal workspace (named by naming none) or an org.
867
- const { fetchOrgs, createOrg, validateHandle, bareHandle } =
868
- await import('../utils/registry-orgs.js')
869
- let envelope
870
- try {
871
- envelope = await fetchOrgs({
872
- apiBase: client.origin,
873
- token: await client.token()
874
- })
875
- } catch (err) {
876
- return { workspace: null, refused: true, reason: err.message }
877
- }
878
- const personalHandle = envelope.account_handle || null
879
- const prompts = (await import('prompts')).default
880
- const choices = [
881
- ...envelope.orgs.map((o) => ({
882
- title: `@${o.handle}${o.handle === personalHandle ? ' — your personal org' : o.is_primary ? ' (primary)' : ''}`,
883
- value: o.handle
884
- })),
885
- {
886
- title: `Personal — no organization${personalHandle ? ` (${personalHandle})` : ''}`,
887
- value: ':personal'
888
- },
889
- { title: 'A new organization…', value: ':new' }
890
- ]
891
- const { choice } = await prompts(
892
- {
893
- type: 'select',
894
- name: 'choice',
895
- message: 'Create this site under which owner?',
896
- choices,
897
- initial: 0
898
- },
899
- {
900
- onCancel: () => {
901
- console.log('\nCancelled.')
902
- process.exit(0)
903
- }
904
- }
905
- )
906
- if (!choice) return { workspace: null, refused: true, reason: 'No owner chosen.' }
907
- if (choice === ':personal') return { workspace: null, source: 'picked' }
908
- if (choice !== ':new') return { workspace: `@${choice}`, source: 'picked' }
909
-
910
- const answer = await prompts(
911
- {
912
- type: 'text',
913
- name: 'handle',
914
- message: 'Org handle (e.g. acme):',
915
- validate: (v) => validateHandle(v) || true
916
- },
917
- {
918
- onCancel: () => {
919
- console.log('\nCancelled.')
920
- process.exit(0)
921
- }
922
- }
923
- )
924
- if (!answer.handle)
925
- return { workspace: null, refused: true, reason: 'No org handle given.' }
926
- try {
927
- const org = await createOrg({
928
- apiBase: client.origin,
929
- token: await client.token(),
930
- handle: bareHandle(answer.handle)
931
- })
932
- return { workspace: `@${org.handle}`, source: 'picked' }
933
- } catch (err) {
934
- return { workspace: null, refused: true, reason: err.message }
935
- }
936
- }
937
-
938
867
  /**
939
868
  * Turn a typed site-create refusal into a sentence, or null to fall through to the
940
869
  * generic HTTP line.
@@ -1048,7 +977,7 @@ export async function ensureSiteExists({
1048
977
 
1049
978
  let res
1050
979
  try {
1051
- // The owner is the workspace the client names — `nameSiteWorkspace` set it.
980
+ // The owner is the workspace the client names — the command's (`workspace.js`).
1052
981
  res = await client.createSite({
1053
982
  name: siteName,
1054
983
  foundation: siteFoundation
@@ -1147,6 +1076,31 @@ async function recordAndDescribeOwner({ client, siteDir, payload, note }) {
1147
1076
  return org
1148
1077
  }
1149
1078
 
1079
+ /**
1080
+ * Stop a push or publish before it sends anything, when a record cannot be sent as
1081
+ * written — the emit's `refusals` (`@uniweb/build`'s `recordsToEntities`).
1082
+ *
1083
+ * ⛔ Checked BEFORE the site is created or a byte uploaded, not only before the send:
1084
+ * both are writes of their own, which a refused records lane does not undo. (The lane
1085
+ * itself is one transaction on the backend since 2026-09-24, so a refusal there writes
1086
+ * nothing. Before that, a refused lane left the folder's entries and entities with no
1087
+ * data behind, and every later push of the site's records was refused — measured
1088
+ * 2026-09-23, from one record written by section.)
1089
+ *
1090
+ * @param {string[]|undefined} refusals
1091
+ * @param {{ error: (m: string) => void, note: (m: string) => void }} report
1092
+ * @returns {boolean} true when the caller must stop
1093
+ */
1094
+ export function refuseUnsendableRecords(refusals, { error, note }) {
1095
+ if (!refusals?.length) return false
1096
+ const n = refusals.length
1097
+ error(
1098
+ `${n === 1 ? 'A record' : `${n} records`} cannot be pushed as written — no content was sent.`
1099
+ )
1100
+ for (const r of refusals) note(` ${r}`)
1101
+ return true
1102
+ }
1103
+
1150
1104
  /**
1151
1105
  * Guarantee we have per-item identity before an identity-bearing push.
1152
1106
  *
@@ -1652,11 +1606,11 @@ export async function pushSyncPackages({
1652
1606
  const harvest = (finalized) => {
1653
1607
  for (const f of finalized || []) {
1654
1608
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
1655
- // Unconditionally, and NOT gated on `changed` — same rule as the entity
1656
- // token: the backend pins "zero-write ⇒ version unmoved", so a no-op
1657
- // resubmit hands back the value we already hold. An older backend omits the
1658
- // field entirely, which leaves the cached tokens alone and degrades to the
1659
- // entity grain, exactly as before.
1609
+ // NOT gated on `changed` — same rule as the entity token: the backend pins
1610
+ // "zero-write ⇒ version unmoved", so a no-op resubmit hands back the value we
1611
+ // already hold. (The site-content lane filters first, `harvestSiteContent`.)
1612
+ // An older backend omits the field entirely, which leaves the cached tokens
1613
+ // alone and degrades to the entity grain, exactly as before.
1660
1614
  if (f.itemVersions) Object.assign(newItemVersions, f.itemVersions)
1661
1615
  }
1662
1616
  }
@@ -1665,6 +1619,27 @@ export async function pushSyncPackages({
1665
1619
  mergeBaseVersions(siteDir, client.origin, newVersions)
1666
1620
  mergeItemBaseVersions(siteDir, client.origin, newItemVersions)
1667
1621
  }
1622
+ // ⛔ The site-content lane banks only the tokens for content this copy holds — the
1623
+ // document we sent against the one the backend stored (`heldTokens`, and the two
1624
+ // losses banking everything caused). What the backend kept for someone else is
1625
+ // collected here, to re-base attribution and to say so.
1626
+ const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
1627
+ const notHeld = { kept: [], foreign: [] }
1628
+ const heldUnits = []
1629
+ const harvestSiteContent = (finalized) => {
1630
+ for (const f of finalized || []) {
1631
+ const held = heldTokens({
1632
+ sent: sentSiteDoc,
1633
+ written: f.document,
1634
+ version: f.version,
1635
+ itemVersions: f.itemVersions
1636
+ })
1637
+ harvest([{ ...f, version: held.version, itemVersions: held.itemVersions }])
1638
+ heldUnits.push(...held.held)
1639
+ notHeld.kept.push(...held.kept)
1640
+ notHeld.foreign.push(...held.foreign)
1641
+ }
1642
+ }
1668
1643
  // The backend's post-write copy of the site-content document, kept for the
1669
1644
  // remote-side unit base (see writeUnitBases).
1670
1645
  let siteFinalizedDoc = null
@@ -1687,7 +1662,7 @@ export async function pushSyncPackages({
1687
1662
  mergeHarvested()
1688
1663
  return { exitCode: 1, finalizedTotal, wrote }
1689
1664
  }
1690
- harvest(finalized)
1665
+ harvestSiteContent(finalized)
1691
1666
  siteFinalizedDoc = finalized[0]?.document || null
1692
1667
  // ⭐ BANK COLLECTION-DECLARATION IDENTITY, the sibling of the folder's
1693
1668
  // placements below. These items have no file to back-fill into — they all
@@ -1731,7 +1706,7 @@ export async function pushSyncPackages({
1731
1706
  if (createdOrg)
1732
1707
  wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
1733
1708
  const createdFinalized = extractFinalized(payload)
1734
- harvest(createdFinalized)
1709
+ harvestSiteContent(createdFinalized)
1735
1710
  siteFinalizedDoc = createdFinalized?.[0]?.document || null
1736
1711
  finalizedTotal += createdFinalized?.length ?? 1
1737
1712
  }
@@ -1801,12 +1776,21 @@ export async function pushSyncPackages({
1801
1776
  // actually shipped — a push that skipped it left that state where it was.
1802
1777
  if (siteContent) {
1803
1778
  const patch = {}
1804
- const ours = entityDocFromUwx(siteContent.buffer)
1779
+ const ours = sentSiteDoc
1805
1780
  if (ours) patch.local = computeUnitHashes(ours)
1806
1781
  // `finalized[].document` is the backend's own representation, read back from
1807
1782
  // the stored row — the only remote-side base a push can produce.
1808
1783
  const theirs = siteFinalizedDoc?.pages ? siteFinalizedDoc : null
1809
- if (theirs) patch.remote = computeUnitHashes(theirs)
1784
+ if (theirs) {
1785
+ patch.remote = computeUnitHashes(theirs)
1786
+ // A unit the backend kept for someone else is not agreed state: its remote base
1787
+ // stays where it was, so a refusal names it as changed upstream.
1788
+ const before = readUnitBases(siteDir, client.origin).remote
1789
+ for (const path of notHeld.kept) {
1790
+ if (before[path]) patch.remote[path] = before[path]
1791
+ else delete patch.remote[path]
1792
+ }
1793
+ }
1810
1794
  if (Object.keys(patch).length) writeUnitBases(siteDir, client.origin, patch)
1811
1795
  // The backend's post-write document carries `$uuid` per item at every nesting
1812
1796
  // level, so the push that just landed also re-arms identity for the next one.
@@ -1834,6 +1818,16 @@ export async function pushSyncPackages({
1834
1818
  )
1835
1819
  }
1836
1820
  }
1821
+ // The files of the units the backend now holds as ours are the synced version — the
1822
+ // ancestor the next `pull --merge` merges from.
1823
+ if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
1824
+ // What the backend holds that this copy doesn't. None of it was overwritten, and no
1825
+ // later push will overwrite it — but the files are behind until a pull.
1826
+ if (notHeld.kept.length || notHeld.foreign.length) {
1827
+ const paths = [...new Set([...notHeld.kept, ...notHeld.foreign])].sort()
1828
+ note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
1829
+ note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
1830
+ }
1837
1831
  // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
1838
1832
  // piece of sync state above is banked: the push itself happened, and the next one must
1839
1833
  // see it. What must not happen is a publish that goes live on top of it, which is why