uniweb 0.70.0 → 0.70.2

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": "uniweb",
3
- "version": "0.70.0",
3
+ "version": "0.70.2",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,16 +42,16 @@
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
44
  "@uniweb/content-writer": "^0.3.4",
45
- "@uniweb/core": "^0.30.0",
46
- "@uniweb/runtime": "^0.27.5",
45
+ "@uniweb/runtime": "^0.27.6",
47
46
  "@uniweb/kit": "^0.19.5",
48
- "@uniweb/schemas": "^0.7.0",
49
- "@uniweb/semantic-parser": "^1.4.1"
47
+ "@uniweb/core": "^0.30.1",
48
+ "@uniweb/semantic-parser": "^1.4.1",
49
+ "@uniweb/schemas": "^0.7.2"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.64.0",
53
52
  "@uniweb/content-reader": "^1.2.5",
54
- "@uniweb/semantic-parser": "^1.4.1"
53
+ "@uniweb/semantic-parser": "^1.4.1",
54
+ "@uniweb/build": "^0.64.2"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -74,10 +74,12 @@ export function resolveBackendOrigin() {
74
74
  * The fallback capability doc for when `GET /dev/config` was not asked for (no
75
75
  * credential in hand) or did not answer. Keeps the client non-breaking.
76
76
  *
77
- * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads exactly one leaf of
78
- * that document — `delivery.siteSubscriptionRequired` — and its absence is meaningful:
79
- * unknown reads falsy, and the caller stays silent rather than claiming a deployment
80
- * does or does not charge. Every other key that used to sit here had no reader.
77
+ * ⭐ **It is EMPTY, and that is the accurate shape.** The CLI reads only that document's
78
+ * `siteContent` key lists — `queryFields`, `pageFields`, `settingsFields` — and their absence
79
+ * is meaningful: unknown means a push sends no optional key, which every deployment accepts,
80
+ * rather than claiming a deployment takes one. Every other key that used to sit here had no
81
+ * reader. *(The leaf named here was `delivery.siteSubscriptionRequired` until 2026-09-26; it
82
+ * went 2026-08-30.)*
81
83
  *
82
84
  * ⛔ **Do not restore a key "for completeness".** A default for a field nothing reads is
83
85
  * a reader waiting to happen, and it is how this file came to describe a client that
@@ -457,9 +459,12 @@ export class BackendClient {
457
459
  * were dropped because a serve location is read from the response that carries it
458
460
  * (`serve_base`, `serve_url`, `config.base`), never from a handshake; `auth.loginPath`
459
461
  * is not read either — the login path is a hardcoded constant
460
- * (`utils/registry-auth.js`). What is actually consumed is ONE leaf —
461
- * `delivery.siteSubscriptionRequired` — and nothing else. Do not add a reader for the
462
- * rest: each one would be a second place a backend's layout is pinned.
462
+ * (`utils/registry-auth.js`). What is actually consumed is the key lists under
463
+ * `siteContent` — `queryFields`, `pageFields`, `settingsFields`, the keys the deployment's
464
+ * Sections declare (`site-sync.js::deploymentFields`) — which is policy, never an address. Do not
465
+ * add a reader for the rest: each one would be a second place a backend's layout is
466
+ * pinned. (This named `delivery.siteSubscriptionRequired` until 2026-09-26; that leaf
467
+ * went 2026-08-30.)
463
468
  *
464
469
  * @returns {Promise<object>}
465
470
  */
@@ -577,6 +582,32 @@ export class BackendClient {
577
582
  }
578
583
  }
579
584
 
585
+ /**
586
+ * GET /dev/site/foundation-schema/{site}/{@scope}/{name}/{version} → the registered version of a
587
+ * foundation a site uses, as the backend holds it: its `schema` (section types with their
588
+ * `data:`), `info`, `data_schemas` (names), `module_url` and `css_url`. Read through the SITE,
589
+ * which is the licence: the caller must be able to read the site, and the site must use the
590
+ * foundation. Null on a 404 (not registered, or not this site's) and on any failure — a caller
591
+ * degrades, since what it is read for can be done without it, less well.
592
+ * @param {string} siteUuid - the site-content uuid
593
+ * @param {string} ref - the catalog ref, `@scope/name@version`
594
+ * @returns {Promise<object|null>}
595
+ */
596
+ async readRegisteredFoundation(siteUuid, ref) {
597
+ const m = /^(@[^/]+)\/([^@/]+)@(.+)$/.exec(String(ref || ''))
598
+ if (!siteUuid || !m) return null
599
+ try {
600
+ const res = await this.request(
601
+ `/dev/site/foundation-schema/${encodeURIComponent(siteUuid)}/${m[1]}/${encodeURIComponent(m[2])}/${encodeURIComponent(m[3])}`
602
+ )
603
+ if (!res.ok) return null
604
+ const body = await res.json().catch(() => null)
605
+ return body && typeof body === 'object' && body.schema && typeof body.schema === 'object' ? body : null
606
+ } catch {
607
+ return null
608
+ }
609
+ }
610
+
580
611
  // ── Orgs ──────────────────────────────────────────────────────────────────────
581
612
 
582
613
  /** GET /dev/orgs → { account_handle, orgs[] }. */
@@ -76,6 +76,11 @@ export function fingerprintDeclaration(declared) {
76
76
  .slice(0, 16)
77
77
  }
78
78
 
79
+ /** Absent, or an empty list — a stored request that asks for nothing. */
80
+ function isNothing(value) {
81
+ return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
82
+ }
83
+
79
84
  /** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
80
85
  function stableString(value) {
81
86
  return JSON.stringify(value, (_k, v) =>
@@ -212,6 +217,12 @@ export function reconcile(localValue, remoteValue, baseFingerprint) {
212
217
  const base = baseFingerprint || null
213
218
 
214
219
  if (local === remote) return { action: 'none', local, remote }
220
+ // ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict. With
221
+ // nothing stored either — an empty list is the store's nothing — there is nothing to do; with
222
+ // something stored, the file is merely behind. Until 2026-09-26 this was a `conflict`, and the
223
+ // first publish of a site whose file is silent warned that its services "were changed
224
+ // elsewhere, and site.yml changed too", listing both as nothing.
225
+ if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
215
226
  if (!base) return { action: 'conflict', local, remote }
216
227
 
217
228
  const localMoved = local !== base
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What `uniweb dev` asks the backend before previewing a site whose foundation is a catalog ref —
3
+ * every clone.
4
+ *
5
+ * Such a site has no build of its foundation, and a build does not turn a ref into a URL: where a
6
+ * version is served is the backend's to say. So the dev server is handed the backend's answer, read
7
+ * for it here through the site — the route push and pull read the version's section types from
8
+ * (`client.readRegisteredFoundation`) — and passed on in `UNIWEB_PREVIEW` (`@uniweb/build/site`'s
9
+ * `preview.js` has the contract). The backend it names is also where the site's kept media URLs are
10
+ * fetched from.
11
+ *
12
+ * ⛔ Nothing here is kept. The answer lives as long as the dev server: `site.yml` keeps the ref, and
13
+ * no file a push or pull reads gets the URL — the round trip never sees it.
14
+ *
15
+ * ⚠️ It may not be known. Whether a backend tells the CLI where a version is served is the backend's
16
+ * to decide, and may change, so an answer without a location is an ordinary outcome: the preview
17
+ * says so and does not start. Nothing else reads it — push, pull and publish work without it.
18
+ */
19
+ import { readBackendState, parseCatalogRef } from '@uniweb/build/uwx'
20
+ import { BackendClient } from './client.js'
21
+ import { resolveWorkspace } from './workspace.js'
22
+ import { readDeclaredFoundation } from '../utils/install-integrity.js'
23
+ import { syncedBackends } from '../utils/site-identity.js'
24
+
25
+ /**
26
+ * What the dev server of `siteDir` is handed, when its foundation is a catalog ref.
27
+ *
28
+ * @param {object} p
29
+ * @param {string} p.siteDir
30
+ * @param {string[]} [p.args]
31
+ * @param {BackendClient} [p.client] - injected in tests
32
+ * @returns {Promise<null
33
+ * | { preview: { backend: string, foundation: { ref: string, url: string, cssUrl: string|null } } }
34
+ * | { refused: string[] }>}
35
+ * null when there is nothing to ask — the foundation is not a catalog ref
36
+ */
37
+ export async function readSitePreview({ siteDir, args = [], client = null }) {
38
+ const ref = readDeclaredFoundation(siteDir)
39
+ if (!parseCatalogRef(ref)) return null
40
+
41
+ client = client || new BackendClient({ args, command: 'Previewing' })
42
+ const uuid = readBackendState(siteDir, client.origin).site?.uuid
43
+ if (!uuid) {
44
+ const known = syncedBackends(siteDir)
45
+ return {
46
+ refused: [
47
+ `This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
48
+ known.length
49
+ ? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --backend <url>`
50
+ : `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
51
+ ]
52
+ }
53
+ }
54
+
55
+ const ws = await resolveWorkspace({ client, args })
56
+ if (ws.refused) return { refused: ['A preview reads the site in one workspace, and none is chosen.', ws.reason] }
57
+ client.setWorkspace(ws.workspace, { source: ws.source })
58
+
59
+ const reply = await client.readRegisteredFoundation(uuid, ref)
60
+ const url = typeof reply?.module_url === 'string' && reply.module_url ? reply.module_url : null
61
+ if (!url) {
62
+ return {
63
+ refused: [
64
+ reply
65
+ ? `${client.origin} did not say where ${ref} is served, so this site cannot be previewed here.`
66
+ : `Could not read ${ref} through this site on ${client.origin}, so this site cannot be previewed here.`,
67
+ 'Push, pull and publish do not need it.'
68
+ ]
69
+ }
70
+ }
71
+ return {
72
+ preview: {
73
+ backend: client.origin,
74
+ foundation: { ref, url, cssUrl: typeof reply.css_url === 'string' && reply.css_url ? reply.css_url : null }
75
+ }
76
+ }
77
+ }
@@ -46,7 +46,10 @@ import {
46
46
  harvestRecordItems,
47
47
  storedRecordItems,
48
48
  reprintRecordItems,
49
- matchStoredRecords
49
+ matchStoredRecords,
50
+ parseCatalogRef,
51
+ readRegisteredFoundation,
52
+ writeRegisteredFoundation
50
53
  } from '@uniweb/build/uwx'
51
54
 
52
55
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -326,18 +329,46 @@ function changedSummary(finalized) {
326
329
  // 404 → null (the emitter then says "register it first"). The bearer is acquired lazily
327
330
  // by the client, so a fully-local sync never authenticates.
328
331
  //
329
- // `offline` (set for `-o` / `--dry-run`) forces every non-local Model to null WITHOUT
330
- // touching the backend — an offline emit must never authenticate.
331
- export function makeModelResolver({ client, offline = false }) {
332
+ // `offline` (set for `-o` / `--dry-run`, and for `status`) never touches the backend — an
333
+ // offline emit must never authenticate. It answers with the Models a push or a pull KEPT for
334
+ // this backend (`keptModels`), or null. ⭐ With `siteDir`, an online read keeps each Model it
335
+ // reads, per backend — so a clone, whose foundation is not in the project to declare them, can
336
+ // still compare offline. ⛔ Until 2026-09-26 nothing was kept, and `uniweb status` in a clone
337
+ // could not resolve a Model ("… could not be resolved").
338
+ export function makeModelResolver({ client, offline = false, siteDir = null, backend = null }) {
339
+ const origin = backend ?? client?.origin ?? null
340
+ const kept = siteDir && origin ? keptModels(siteDir, origin) : {}
332
341
  const cache = new Map()
333
342
  return async (modelName) => {
334
343
  if (cache.has(modelName)) return cache.get(modelName)
335
- const decl = offline ? null : await client.readDataSchema(modelName)
344
+ let decl
345
+ if (offline) {
346
+ decl = kept[modelName] ?? null
347
+ } else {
348
+ decl = await client.readDataSchema(modelName)
349
+ if (decl && siteDir && origin) keepModels(siteDir, origin, { [modelName]: decl })
350
+ }
336
351
  cache.set(modelName, decl)
337
352
  return decl
338
353
  }
339
354
  }
340
355
 
356
+ /**
357
+ * The Models a push or a pull read from this backend, by name — kept in `.uniweb/backend-cache.json`
358
+ * so an offline emit resolves what an online one did. Regenerable: losing them costs `status` its
359
+ * comparison until the next push or pull, never a wrong answer.
360
+ */
361
+ export function keptModels(siteDir, backend) {
362
+ const models = readSyncCacheFile(siteDir, backend).models
363
+ return models && typeof models === 'object' ? models : {}
364
+ }
365
+
366
+ /** Keep Models read from this backend, beside the ones already kept. */
367
+ export function keepModels(siteDir, backend, models) {
368
+ if (!models || !Object.keys(models).length) return
369
+ updateSyncCache(siteDir, backend, { models: { ...keptModels(siteDir, backend), ...models } })
370
+ }
371
+
341
372
  // `.uniweb/backend-cache.json` — what a backend had and what we last sent it, so the
342
373
  // next transfer can skip work. Gitignored, per clone, safe to delete.
343
374
  //
@@ -653,6 +684,55 @@ export function readAppliedInjections(siteDir, backend) {
653
684
  return readMap(siteDir, backend, 'applied')
654
685
  }
655
686
 
687
+ /**
688
+ * ⭐ THE REGISTERED FOUNDATION A CLONE KEEPS. A site whose foundation is a catalog ref has no build of
689
+ * it in the project, and the section types' `data:` it declares are what type a query named for a
690
+ * data key — `team:` read as `@acme/member` (`typed_by_data_key`). Read once from the backend,
691
+ * through the site, and kept under `.uniweb/` (`@uniweb/build`'s `registered-foundation.js`), where
692
+ * the build, the push and the pull read it; a registered version never changes, so a kept copy is
693
+ * never read again.
694
+ *
695
+ * Null when the foundation is not a catalog ref, the site is not on this backend yet, or the read
696
+ * fails: nothing is then typed by it — a pull writes such a query's schema out, and a push sends
697
+ * what that says — so both go on.
698
+ */
699
+ export async function ensureRegisteredFoundation({ client, siteDir, siteUuid, ref }) {
700
+ if (!parseCatalogRef(ref)) return null
701
+ const kept = readRegisteredFoundation(siteDir, ref)
702
+ if (kept) return kept
703
+ const reply = siteUuid ? await client.readRegisteredFoundation(siteUuid, ref) : null
704
+ if (!reply) return null
705
+ // ⛔ ONLY WHAT WE READ — the section types. The reply carries more than we asked for, a serve
706
+ // location among it, and a serve location is decided at publish and read from the response that
707
+ // carries it, never kept. Until 2026-09-26 the whole reply was kept.
708
+ const sectionTypes = { schema: reply.schema }
709
+ writeRegisteredFoundation(siteDir, ref, sectionTypes)
710
+ return sectionTypes
711
+ }
712
+
713
+ // The Sections whose declared keys a push reads before it sends an optional one.
714
+ const DEPLOYMENT_FIELD_LISTS = ['queryFields', 'pageFields', 'settingsFields']
715
+
716
+ /**
717
+ * The keys this deployment's `queries`, page and `settings` Sections declare, as it says so itself —
718
+ * `GET /dev/config` → `siteContent.queryFields` / `pageFields` / `settingsFields` — so a push sends
719
+ * an optional key only to a deployment that takes it (`typed_by_data_key`; `og_title` and
720
+ * `og_description`): one that does not declare a key refuses a push carrying it. Offline — a dry
721
+ * run, `--output` — the lists the last push to this backend banked, so the package is the one that
722
+ * push would build.
723
+ *
724
+ * Only the lists in hand are returned, as emit options: with none, nothing optional is sent, which
725
+ * every deployment accepts.
726
+ */
727
+ export async function deploymentFields({ client, siteDir, offline = false }) {
728
+ const source = offline
729
+ ? readAppliedInjections(siteDir, client.origin)
730
+ : (await client.discover())?.siteContent
731
+ const out = {}
732
+ for (const name of DEPLOYMENT_FIELD_LISTS) if (Array.isArray(source?.[name])) out[name] = source[name]
733
+ return out
734
+ }
735
+
656
736
  /**
657
737
  * Bank the content hashes and the injections that produced them. ⛔ ONE call, both
658
738
  * maps: they describe the same document, so writing either alone leaves the cache
@@ -1430,7 +1510,7 @@ export async function probeUnpushed(siteDir, { backend = null, sendAll = false }
1430
1510
  */
1431
1511
  async function comparisonEmit(
1432
1512
  siteDir,
1433
- { backend = null, priorHashes = {}, sendAll = false, declarations = null } = {}
1513
+ { backend = null, priorHashes = {}, sendAll = false, declarations = null, fields = null } = {}
1434
1514
  ) {
1435
1515
  const applied = readAppliedInjections(siteDir, backend)
1436
1516
  // ⛔ Per backend: an asset id is minted by one and means nothing to another, so a
@@ -1446,10 +1526,13 @@ async function comparisonEmit(
1446
1526
  // of its Models here, and nothing is banked.
1447
1527
  resolveModel: declarations
1448
1528
  ? async (name) => declarations.get(name) ?? null
1449
- : makeModelResolver({ client: null, offline: true }),
1529
+ : makeModelResolver({ client: null, offline: true, siteDir, backend }),
1450
1530
  priorHashes,
1451
1531
  sendAll,
1452
1532
  ...applied,
1533
+ // The deployment's own answer (`deploymentFields`), when the caller has just read it, over what
1534
+ // the last push banked.
1535
+ ...(fields || {}),
1453
1536
  ...(Object.keys(queryUuids).length ? { queryUuids } : {}),
1454
1537
  ...(Object.keys(assetIds).length ? { assetIds } : {})
1455
1538
  // ⛔ No `org`: a site's `@/x` refs resolve into its FOUNDATION's scope, which the
@@ -1484,9 +1567,12 @@ async function comparisonEmit(
1484
1567
  * @param {Map<string,object>} [opts.declarations] - the Models the pull read, by name — so a
1485
1568
  * site whose foundation is not in the project (a clone's) can be re-banked offline
1486
1569
  */
1487
- export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations } = {}) {
1488
- const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations })
1489
- writeSyncCache(siteDir, backend, pkg.hashes || {}, pkg.applied || {})
1570
+ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, declarations, fields = null } = {}) {
1571
+ // ⛔ With the keys this deployment's Sections take, when the caller has them (`deploymentFields`):
1572
+ // they shape the document the next push sends (`typed_by_data_key`, `og_title`), so a hash banked
1573
+ // without them marks a clone's first push as changed. Until 2026-09-26 a pull banked none.
1574
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
1575
+ writeSyncCache(siteDir, backend, onlyWhatThePullTook(siteDir, backend, pkg, recordDocs), pkg.applied || {})
1490
1576
  // ⭐ And the identity of the records' list items, from the documents the pull just took:
1491
1577
  // the files it wrote hold the stored items in stored order, so the emit over them pairs
1492
1578
  // with each document item for item.
@@ -1505,6 +1591,44 @@ export async function rebankSyncHashes(siteDir, backend = null, { recordDocs, de
1505
1591
  return Object.keys(pkg.hashes || {}).length
1506
1592
  }
1507
1593
 
1594
+ /**
1595
+ * The hashes a pull may bank: the tree's, except for a record the pull did not take.
1596
+ *
1597
+ * ⛔ ONLY WHAT CAME FROM THE BACKEND IS AGREED. A record on disk that the backend did not
1598
+ * hand back — written since the last push, or one a push had refused — is not in sync
1599
+ * because a pull ran. Until 2026-09-26 it was banked with the rest, so every later push
1600
+ * skipped it and `uniweb status` said "in sync": a record written before a `uniweb sync`
1601
+ * (which pulls, then pushes) never reached the backend. One this backend never accepted —
1602
+ * no identity from it — is always sent: a hash it has banked can only be that mistake's.
1603
+ * One it did accept keeps what the last push banked. The folder keeps its hash too while
1604
+ * it places such a record. A pull that took no records (`--no-records`, a 304) leaves
1605
+ * every record's hash as it was.
1606
+ *
1607
+ * @param {object[]|undefined} recordDocs - the record documents the pull took
1608
+ */
1609
+ function onlyWhatThePullTook(siteDir, backend, pkg, recordDocs) {
1610
+ const hashes = { ...(pkg.hashes || {}) }
1611
+ const prior = readSyncCache(siteDir, backend)
1612
+ const keep = (key) => {
1613
+ if (Object.hasOwn(prior, key)) hashes[key] = prior[key]
1614
+ else delete hashes[key]
1615
+ }
1616
+ const taken = new Set((recordDocs || []).map((d) => d?.$uuid).filter(Boolean))
1617
+ const recordUuids = readBackendState(siteDir, backend).records || {}
1618
+ let notTaken = 0
1619
+ for (const entry of pkg.records?.index || []) {
1620
+ if (entry.kind === 'folder') continue // the folder itself — held below while it places one
1621
+ const theirs = entry.ownId ? recordUuids[entry.ownId] : null
1622
+ if (theirs && taken.has(theirs)) continue
1623
+ notTaken++
1624
+ const key = `${entry.model} ${entry.id}`
1625
+ if (theirs) keep(key)
1626
+ else delete hashes[key]
1627
+ }
1628
+ if (notTaken) for (const key of Object.keys(hashes)) if (key.startsWith('@uniweb/folder ')) keep(key)
1629
+ return hashes
1630
+ }
1631
+
1508
1632
  /**
1509
1633
  * Re-bank the hashes of the records a push sent naming a record it created — `keys`,
1510
1634
  * the emit's `namesNew` — as the next push will hash them, and fingerprint their list
@@ -1714,6 +1838,18 @@ export async function pushSyncPackages({
1714
1838
  if (problem.detail) note(String(problem.detail))
1715
1839
  return null
1716
1840
  }
1841
+ // ⭐ A RECORD KEEPS ITS TYPE. A record file moved to another type's folder keeps its `$uuid`, and
1842
+ // the backend refuses it as that type (`type_changed`, naming the record and both types), before
1843
+ // writing anything. Storing it as the new type makes it a new record.
1844
+ if (problem?.reason === 'type_changed') {
1845
+ error(`${label} push refused — a stored record would change its type.`)
1846
+ if (problem.detail) note(String(problem.detail))
1847
+ note(
1848
+ 'A record keeps its type. To store it as the new type, make it a new record: delete the ' +
1849
+ '`$uuid` line from its file, then push again.'
1850
+ )
1851
+ return null
1852
+ }
1717
1853
  if (problem?.reason === 'stale_base') {
1718
1854
  error(
1719
1855
  `${label} push refused — the backend has newer content than your last pull.`
@@ -1798,15 +1934,14 @@ export async function pushSyncPackages({
1798
1934
  ` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
1799
1935
  )
1800
1936
  note('Deleting this folder removes only your local copy, either way.')
1801
- } else if (res.status === 409) {
1802
- // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
1803
- // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
1804
- note(
1805
- "This site's record structure is already established on the backend and can't be changed " +
1806
- 'in place — e.g. adding or removing a schema-backed query, or switching one between ' +
1807
- 'static (data-bundle) and schema-backed delivery. To change it, push it as a new site: ' +
1808
- `uniweb forget --backend ${client.origin}, then push again (the old site stays until it is deleted).`
1809
- )
1937
+ } else if (res.status === 409 && (problem?.title || problem?.detail)) {
1938
+ // ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
1939
+ // an append-only section — so it is shown as the backend put it, not explained by us. Until
1940
+ // 2026-09-26 every one was explained as "this site's record structure is already established
1941
+ // … push it as a new site", a rule the backend no longer has, and it misled over a refused
1942
+ // reference. (`stale_base` and `item_uuid_conflict` have their own explanations, above.)
1943
+ note([problem.title, problem.detail].filter(Boolean).join(' — '))
1944
+ return null
1810
1945
  }
1811
1946
  if (body) note(body.slice(0, 800))
1812
1947
  return null
@@ -426,6 +426,12 @@ export async function clone(args = [], deps = {}) {
426
426
 
427
427
  const pullExtra = []
428
428
  if (noRecords) pullExtra.push('--no-records')
429
+ // The workspace this clone was told to work in: the pull is another process, and asks the same
430
+ // question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
431
+ // refused: "none is chosen". A workspace from the login or the environment reaches it by itself.
432
+ const orgFlag = flagValue(args, '--org')
433
+ if (orgFlag) pullExtra.push('--org', orgFlag)
434
+ if (args.includes('--personal')) pullExtra.push('--personal')
429
435
 
430
436
  if (deps.skipPull) {
431
437
  note('Skipping pull (test mode).')
@@ -24,6 +24,14 @@
24
24
  * site by default (mirrors the `pnpm dev` shortcut `uniweb create` writes).
25
25
  * Use `--site` to pick a different one without editing the root scripts.
26
26
  *
27
+ * A site whose foundation is a catalog ref — every clone — has no build of it, and
28
+ * only the backend the site is on can say where that version is served. So `dev`
29
+ * asks it, through the site, and hands the answer to the dev server it starts in its
30
+ * environment (`UNIWEB_PREVIEW`), with the backend it asked, which is also where the
31
+ * site's kept media URLs are fetched from. Nothing is written: `site.yml` keeps the
32
+ * ref and the round trip never sees the URL. A backend that does not say is an
33
+ * ordinary answer — `dev` says so and starts nothing (`backend/site-preview.js`).
34
+ *
27
35
  * Implementation: shells out to the package manager that invoked the CLI
28
36
  * (detected via npm_config_user_agent), running the workspace-filtered
29
37
  * dev command (`pnpm --filter <name> dev` or `npm -w <name> run dev`).
@@ -121,12 +129,32 @@ export async function dev(args = []) {
121
129
  const [bin, ...rest] = command.split(' ')
122
130
  const sitePath = join(rootDir, site.path)
123
131
 
132
+ // ⭐ A SITE WHOSE FOUNDATION IS A CATALOG REF — a clone — is previewed with what the backend
133
+ // it is on says: where that version is served. Asked here, for this dev server alone, and
134
+ // handed to it in its environment; nothing is written (`backend/site-preview.js`). Any other
135
+ // site needs nothing from a backend, and this asks nothing.
136
+ const { readSitePreview } = await import('../backend/site-preview.js')
137
+ const asked = await readSitePreview({ siteDir: sitePath, args })
138
+ if (asked?.refused) {
139
+ console.error(`${RED}✗${RESET} ${asked.refused[0]}`)
140
+ for (const line of asked.refused.slice(1)) console.error(` ${line}`)
141
+ process.exit(1)
142
+ }
143
+ let env = process.env
144
+ if (asked?.preview) {
145
+ const { PREVIEW_ENV, encodePreview } = await import('@uniweb/build/site')
146
+ env = { ...process.env, [PREVIEW_ENV]: encodePreview(asked.preview) }
147
+ console.error(
148
+ `${DIM}→ Previewing with ${asked.preview.foundation.ref}, served by ${asked.preview.backend}${RESET}`
149
+ )
150
+ }
151
+
124
152
  console.error(
125
153
  `${DIM}→ ${command}${RESET} ${DIM}(site: ${site.name}, dir: ${sitePath})${RESET}`
126
154
  )
127
155
  console.error('')
128
156
 
129
- const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit' })
157
+ const child = spawn(bin, rest, { cwd: rootDir, stdio: 'inherit', env })
130
158
  child.on('close', (code) => process.exit(code ?? 0))
131
159
  child.on('error', (err) => {
132
160
  console.error(`${RED}✗${RESET} Failed to start dev server: ${err.message}`)
@@ -87,6 +87,8 @@ import {
87
87
  ensureItemUuids,
88
88
  readFolderItemUuids,
89
89
  readRecordItemUuids,
90
+ deploymentFields,
91
+ ensureRegisteredFoundation,
90
92
  recoverUnbankedIdentity,
91
93
  ensureSiteExists,
92
94
  refuseUnsendableRecords,
@@ -339,9 +341,11 @@ export async function publish(args = []) {
339
341
  // (2026-08-30). Restoring a reader for it would re-create a check that cannot fail
340
342
  // while implying a capability that was never negotiable.
341
343
  //
342
- // Discovery is not consulted on this path at all. The one leaf the CLI still reads
343
- // (`delivery.siteSubscriptionRequired`) is read after the site create, where a
344
- // credential is already in hand.
344
+ // Discovery is not consulted for a gate: the CLI reads the key lists under `siteContent`
345
+ // (`queryFields`, `pageFields`, `settingsFields`), which say what the push below may SEND
346
+ // (`deploymentFields`), never whether to publish. (`delivery.siteSubscriptionRequired`
347
+ // was deleted 2026-08-30; this said it was still read, after the site create, until
348
+ // 2026-09-26.)
345
349
 
346
350
  // ⛔ NOTHING about a runtime is sent from here. `site.yml::runtime` was a
347
351
  // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
@@ -550,7 +554,7 @@ export async function publish(args = []) {
550
554
  }
551
555
 
552
556
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
553
- const resolveModel = makeModelResolver({ client, offline: false })
557
+ const resolveModel = makeModelResolver({ client, offline: false, siteDir })
554
558
 
555
559
  // ⛔ AN EMPTY RECORDS DIRECTORY REMOVES. It is the one path where an ordinary act
556
560
  // is destructive — a directory emptied by accident, or kept with only a
@@ -961,8 +965,19 @@ export async function publish(args = []) {
961
965
  // edited since this clone last synced, the push is refused rather than
962
966
  // overwriting them, and nothing goes live. `--force` drops the precondition.
963
967
  const forced = args.includes('--force')
968
+ const fields = await deploymentFields({ client, siteDir })
969
+ // A foundation named by catalog ref — a clone's: its kept registered version types a query named
970
+ // for a data key (`ensureRegisteredFoundation`).
971
+ await ensureRegisteredFoundation({
972
+ client,
973
+ siteDir,
974
+ siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
975
+ ref: siteYml?.foundation
976
+ })
964
977
  const emitOptions = {
965
978
  backend: client.origin,
979
+ // The keys this deployment's Sections take — see deploymentFields.
980
+ ...fields,
966
981
  ...(declaration.declare ? {} : { declareServices: false }),
967
982
  // Placement identity for the folder — see writeFolderItemUuids.
968
983
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
@@ -60,7 +60,9 @@ import {
60
60
  writeFileSync,
61
61
  mkdirSync,
62
62
  mkdtempSync,
63
- rmSync
63
+ rmSync,
64
+ readdirSync,
65
+ statSync
64
66
  } from 'node:fs'
65
67
  import { tmpdir } from 'node:os'
66
68
  import { createHash } from 'node:crypto'
@@ -87,6 +89,8 @@ import {
87
89
  } from '../utils/pull-written.js'
88
90
  import {
89
91
  makeModelResolver,
92
+ ensureRegisteredFoundation,
93
+ deploymentFields,
90
94
  rebankSyncHashes,
91
95
  writeQueryUuids,
92
96
  mergeBaseVersions,
@@ -427,6 +431,27 @@ function readManifestTokens(buf) {
427
431
  * @param {object} [deps] - injectable seams for testing: `fetch` (default global
428
432
  * fetch), `resolveSiteDir`, `getToken` (skip auth).
429
433
  */
434
+ // Every file under `roots` (site-relative paths, files or directories), site-relative.
435
+ function filesUnder(siteDir, roots) {
436
+ const out = []
437
+ const walk = (rel) => {
438
+ const abs = join(siteDir, rel)
439
+ let stat
440
+ try {
441
+ stat = statSync(abs)
442
+ } catch {
443
+ return
444
+ }
445
+ if (stat.isDirectory()) {
446
+ for (const name of readdirSync(abs)) walk(join(rel, name))
447
+ } else if (stat.isFile()) {
448
+ out.push(rel)
449
+ }
450
+ }
451
+ for (const root of roots) walk(root)
452
+ return out
453
+ }
454
+
430
455
  /**
431
456
  * Refuse a pull that would overwrite unsaved work. Returns a result object to
432
457
  * return from `pull`, or null to proceed.
@@ -440,7 +465,12 @@ async function checkWorkingTree(siteDir, args) {
440
465
  let dirty = uncommittedUnder(siteDir, roots)
441
466
 
442
467
  if (dirty === null) {
443
- // Not a git work tree. Nothing to fall back on if this goes wrong.
468
+ // Not a git work tree. Nothing to fall back on if this goes wrong — unless nothing here is the
469
+ // author's: every file under pull's roots is one a pull, or the clone that made this site, wrote
470
+ // and nothing has changed since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull,
471
+ // which runs with no terminal to ask, refused here, and `uniweb clone` could not finish.
472
+ const written = readWritten(siteDir)
473
+ if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
444
474
  if (isNonInteractive(args)) {
445
475
  error('Refusing to pull: this site is not in a git repository.')
446
476
  note(
@@ -685,7 +715,7 @@ export async function pull(args = [], deps = {}) {
685
715
  command: 'Pulling'
686
716
  })
687
717
  // One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
688
- const readModel = makeModelResolver({ client })
718
+ const readModel = makeModelResolver({ client, siteDir })
689
719
 
690
720
  // ⭐ No scope check any more — and none is needed. This project's identity on
691
721
  // `client.origin` is read from that origin's own section of sync.json, so a
@@ -957,6 +987,11 @@ export async function pull(args = [], deps = {}) {
957
987
  // unreadable here
958
988
  }
959
989
  }
990
+ // A foundation named by catalog ref — a clone's: keep its registered version before the
991
+ // queries are written, since its section types are what type a query named for a data key.
992
+ if (!keepAuthoredFoundation) {
993
+ await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
994
+ }
960
995
  const report = siteContentDocumentToProject({
961
996
  document: siteDoc,
962
997
  siteRoot: siteDir,
@@ -1128,7 +1163,8 @@ export async function pull(args = [], deps = {}) {
1128
1163
  }
1129
1164
  await rebankSyncHashes(siteDir, client.origin, {
1130
1165
  recordDocs: pulledRecordDocs,
1131
- ...(declarations.size ? { declarations } : {})
1166
+ ...(declarations.size ? { declarations } : {}),
1167
+ fields: await deploymentFields({ client, siteDir })
1132
1168
  })
1133
1169
  } catch (err) {
1134
1170
  note(`! could not re-bank the sync cache: ${err.message}`)
@@ -83,6 +83,8 @@ import {
83
83
  readFolderItemUuids,
84
84
  readQueryUuids,
85
85
  readRecordItemUuids,
86
+ deploymentFields,
87
+ ensureRegisteredFoundation,
86
88
  recoverUnbankedIdentity,
87
89
  ensureItemUuids,
88
90
  refuseUnsendableRecords,
@@ -405,7 +407,7 @@ export async function push(args = [], deps = {}) {
405
407
  // Placement identity for the folder — see writeFolderItemUuids.
406
408
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
407
409
  ...(foundationDir ? { foundationDir } : {}),
408
- resolveModel: makeModelResolver({ client, offline: false })
410
+ resolveModel: makeModelResolver({ client, offline: false, siteDir })
409
411
  })
410
412
  mediaRefs = probe.localAssets || []
411
413
  refusals = probe.refusals || []
@@ -498,6 +500,21 @@ export async function push(args = [], deps = {}) {
498
500
  output || dryRun
499
501
  ? readItemUuids(siteDir, client.origin)
500
502
  : await ensureItemUuids({ client, siteDir, note })
503
+ const fields = await deploymentFields({
504
+ client,
505
+ siteDir,
506
+ offline: Boolean(output) || dryRun
507
+ })
508
+ // A foundation named by catalog ref — a clone's: its kept registered version types a query named
509
+ // for a data key (`ensureRegisteredFoundation`). Offline, only what is already kept.
510
+ if (!output && !dryRun) {
511
+ await ensureRegisteredFoundation({
512
+ client,
513
+ siteDir,
514
+ siteUuid: readBackendState(siteDir, client.origin).site?.uuid,
515
+ ref: siteYml?.foundation
516
+ })
517
+ }
501
518
  const emitOptions = {
502
519
  backend: client.origin,
503
520
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -505,12 +522,15 @@ export async function push(args = [], deps = {}) {
505
522
  // Identity for the `queries` section — see readQueryUuids. Keyed by
506
523
  // name, because a declaration has no file for a path-keyed map to hold.
507
524
  queryUuids: readQueryUuids(siteDir, client.origin),
525
+ // The keys this deployment's Sections take — see deploymentFields.
526
+ ...fields,
508
527
  // Identity for the records' list items — see readRecordItemUuids.
509
528
  recordItemUuids: readRecordItemUuids(siteDir, client.origin),
510
529
  ...(foundationDir ? { foundationDir } : {}),
511
530
  resolveModel: makeModelResolver({
512
531
  client,
513
- offline: Boolean(output) || dryRun
532
+ offline: Boolean(output) || dryRun,
533
+ siteDir
514
534
  }),
515
535
  priorHashes,
516
536
  sendAll,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-26T17:05:22.846Z",
3
+ "generatedAt": "2026-09-27T03:02:47.415Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.3",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.64.0",
13
+ "version": "0.64.2",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.30.0",
38
+ "version": "0.30.1",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.5",
86
+ "version": "0.27.6",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.7.0",
99
+ "version": "0.7.2",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },