@uniweb/build 0.55.0 → 0.56.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.
@@ -1,156 +1,34 @@
1
1
  /**
2
- * `assets.json` — the site's committed map from a local asset path to the
3
- * backend's content-addressed id.
4
- *
5
- * ## Why it is COMMITTED, and why that is the whole point
6
- *
7
- * The same map used to be built on every push and thrown away
8
- * (`assetsByLocalUrl`, in memory, consumed by the content rewrite and dropped).
9
- * Rebuilt per push it is a private detail; committed it is a **log every machine
10
- * collaborating on this site can read**:
11
- *
12
- * - a teammate who clones knows which bytes the project expects to exist and
13
- * can fetch the ones they lack, instead of discovering a missing image at
14
- * render;
15
- * - `pull` can put an asset back at **the path its author wrote**, rather than
16
- * inventing one — without this, a push turns `/images/hero.png` into an
17
- * opaque id and nothing remembers it was ever called that;
18
- * - a re-push of unchanged media is visibly a no-op rather than a silent one.
19
- *
20
- * ⇒ It is project state, not cache. `.uniweb/` is gitignored in both scaffolded
21
- * templates, which is why this does not live there.
22
- *
23
- * ## ⛔ Diff stability is a hard requirement, not tidiness
24
- *
25
- * A committed file that reorders itself produces a spurious diff on every push
26
- * and trains people to stop reading it — at which point a real change to what a
27
- * site ships passes unnoticed. Keys are therefore **sorted**, the shape is flat,
28
- * and the writer is a no-op when nothing changed (it does not rewrite an
29
- * identical file, so `git status` stays clean on a push that moved no assets).
30
- *
31
- * ## What it deliberately does NOT hold
32
- *
33
- * ⛔ **No serve URL.** A URL is a host's route layout, and storing one here would
34
- * re-create — in a committed file, on every machine — exactly the coupling that
35
- * deleting `buildAssetUrl` removed from this CLI. The id plus the host's
36
- * `config.assets.url` pattern is the whole address, and only the host owns the
37
- * second half.
38
- *
39
- * ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
40
- * difference is the point: a hash can recognize an address and cannot compose one.
41
- * It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
42
- * `seo.image`, a section param — where there is no object to stamp `assetId` beside,
43
- * so the stored value is the serve URL alone. `pull` hashes such a string and, when
44
- * it matches, puts back the path the author wrote. Should the host ever serve an
45
- * asset at a new address, the fingerprint simply stops matching and the pull leaves
46
- * the URL — the honest projection — until the next push records the new one.
47
- *
48
- * ⛔ **No mime or size.** The store validates those and they are its to change;
49
- * a second copy here is a second thing to disagree.
2
+ * Asset reference helpers — two pure functions, no file.
3
+ *
4
+ * ⚠️ **The MAP moved to `sync.json` on 2026-09-20** (`sync-store.js`,
5
+ * `backends.<origin>.assets`). It lived here as `assets.json`, a single flat
6
+ * `local path → id` file that assumed ONE backend — which is exactly what it
7
+ * could not represent once a project syncs with several: an id is minted by a
8
+ * backend and means nothing to any other one. `readAssetMap` / `updateAssetMap` /
9
+ * `refForAssetId` / `ASSET_MAP_FILE` went with it.
10
+ *
11
+ * What stays here is what never touched the filesystem:
12
+ *
13
+ * - **`servedFingerprint`** — hashes a serve URL. It exists for references that are
14
+ * a BARE STRING (`info.preview`, `seo.image`, a section param), where there is no
15
+ * object to stamp an id beside, so the stored value is the address alone. ⛔ A
16
+ * fingerprint rather than the URL because a hash can RECOGNIZE an address and
17
+ * cannot COMPOSE one — route layout is the host's and never ours to rebuild.
18
+ * - **`restoreAssetRefs`** — puts an author's own paths back into a document pulled
19
+ * from a backend. Without it a push/pull cycle rewrites every image in a
20
+ * developer's source to a backend route: a mangling of files they own, by a round
21
+ * trip that changed nothing.
22
+ *
23
+ * ⭐ **`restoreAssetRefs` no-ops on an empty map**, which is what makes the store's
24
+ * `backend` parameter honest: a projection not tied to a backend has no known
25
+ * assets, so it restores nothing rather than guessing.
26
+ *
27
+ * @module
50
28
  */
51
29
 
52
- import { existsSync, readFileSync, writeFileSync } from 'node:fs'
53
30
  import { createHash } from 'node:crypto'
54
31
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
55
- import { join } from 'node:path'
56
-
57
- export const ASSET_MAP_FILE = 'assets.json'
58
-
59
- /** Current on-disk shape. Bumped only for a breaking layout change. */
60
- const VERSION = 1
61
-
62
- const mapPath = (siteDir) => join(siteDir, ASSET_MAP_FILE)
63
-
64
- /**
65
- * Read the site's asset map. A missing, unreadable or malformed file reads as
66
- * empty rather than throwing: the map is an accelerator and a record, and a
67
- * corrupt one must never be the reason a push fails. The next write repairs it.
68
- *
69
- * @param {string} siteDir
70
- * @returns {Record<string, { id: string, ext: string }>} local ref → identity
71
- */
72
- export function readAssetMap(siteDir) {
73
- const p = mapPath(siteDir)
74
- if (!existsSync(p)) return {}
75
- try {
76
- const parsed = JSON.parse(readFileSync(p, 'utf8'))
77
- const assets = parsed?.assets
78
- if (!assets || typeof assets !== 'object') return {}
79
- const out = {}
80
- for (const [ref, v] of Object.entries(assets)) {
81
- if (v && typeof v.id === 'string' && v.id) {
82
- out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
83
- if (typeof v.served === 'string' && v.served) out[ref].served = v.served
84
- }
85
- }
86
- return out
87
- } catch {
88
- return {}
89
- }
90
- }
91
-
92
- /**
93
- * Merge `entries` into the site's map and write it if anything changed.
94
- *
95
- * MERGE, not replace: a push carries only the refs that page's content touched,
96
- * so replacing would drop every asset this run happened not to mention — which
97
- * is most of them on a partial push. An entry whose id changed is overwritten
98
- * (the bytes at that path changed); an identical entry is left alone.
99
- *
100
- * @param {string} siteDir
101
- * @param {Record<string, { id: string, ext: string }>} entries
102
- * @returns {{ added: string[], changed: string[], written: boolean }}
103
- */
104
- export function updateAssetMap(siteDir, entries) {
105
- const prior = readAssetMap(siteDir)
106
- const added = []
107
- const changed = []
108
-
109
- for (const [ref, v] of Object.entries(entries || {})) {
110
- if (!v?.id) continue
111
- const was = prior[ref]
112
- // A new `served` fingerprint is a change too: the host serves these bytes at
113
- // another address now, and the old fingerprint would stop recognizing it. An
114
- // entry that carries none — a download learns identity, not an upload's URL —
115
- // keeps the one already recorded for the same bytes.
116
- const served = v.served || (was && was.id === v.id ? was.served : undefined)
117
- if (!was) added.push(ref)
118
- else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
119
- else continue
120
- prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
121
- }
122
-
123
- if (!added.length && !changed.length) return { added, changed, written: false }
124
-
125
- // Sorted keys + a trailing newline: a committed file that reorders itself
126
- // produces a diff on every push and teaches people to skip reading it.
127
- const assets = {}
128
- for (const ref of Object.keys(prior).sort()) assets[ref] = prior[ref]
129
- writeFileSync(
130
- mapPath(siteDir),
131
- JSON.stringify({ version: VERSION, assets }, null, 2) + '\n'
132
- )
133
- return { added, changed, written: true }
134
- }
135
-
136
- /**
137
- * The local ref a given asset id is known at, or null.
138
- *
139
- * This is the direction `pull` needs and the reason the map is worth
140
- * committing: stored content carries an id, and only this can say the author
141
- * called it `/images/hero.png`. Without it a pull must invent a path.
142
- *
143
- * @param {Record<string, { id: string, ext: string }>} map
144
- * @param {string} id
145
- * @returns {string|null}
146
- */
147
- export function refForAssetId(map, id) {
148
- if (!id) return null
149
- for (const [ref, v] of Object.entries(map || {})) {
150
- if (v.id === id) return ref
151
- }
152
- return null
153
- }
154
32
 
155
33
  /**
156
34
  * The fingerprint `assets.json` keeps of the URL a host serves an asset at.
@@ -318,13 +318,21 @@ function writeIfChanged(filePath, text) {
318
318
  * order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
319
319
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
320
320
  * @param {string} [params.sourceLocale]
321
- * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[] }}
321
+ * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string> }}
322
+ * `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
322
323
  */
323
324
  export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
324
325
  const updated = []
325
326
  const unchanged = []
326
327
  const deferred = []
327
328
  const warnings = []
329
+ // ⭐ own id → the uuid THIS backend minted. The caller files it under the backend in
330
+ // `sync.json::backends.<origin>.records`; this function knows no backend.
331
+ const mapped = {}
332
+ // Files holding only records that already carry their own id. Counted ONCE per file
333
+ // at the end: an array-form file holds several records, and "examined, nothing
334
+ // written" is a statement about the file, not about each record in it.
335
+ const ownIdFiles = new Set()
328
336
  // Multi-record files are written ONCE per file, applying every (slug → uuid).
329
337
  const arrayFiles = new Map() // array-form YAML/JSON: sourceFile -> Map(slug -> uuid)
330
338
  const bibFiles = new Map() // BibTeX: sourceFile -> Map(cite key -> uuid)
@@ -347,6 +355,22 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
347
355
  deferred.push({ index: i, id: entry.id, reason: 'no source file on disk' })
348
356
  continue
349
357
  }
358
+
359
+ // ⭐ Every record the backend returned is mapped: own id → the uuid it minted.
360
+ // A record new to EVERY backend has no own id yet, so the minted uuid becomes it
361
+ // (written below) and the mapping is identity — which is exactly the behaviour
362
+ // before identity was keyed by backend, so a single-backend project is unchanged.
363
+ mapped[entry.ownId || uuid] = uuid
364
+
365
+ // ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
366
+ // and travels with the file; this backend's uuid belongs in the map, not in the
367
+ // author's file. And it must not be: variant A below re-renders the WHOLE file from
368
+ // the backend's document, which carries THIS backend's `$uuid` — so a second
369
+ // backend would silently overwrite the record's identity with its own.
370
+ if (entry.ownId) {
371
+ ownIdFiles.add(entry.sourceFile)
372
+ continue
373
+ }
350
374
  if (entry.multiRecord) {
351
375
  // Many records in one file → group by file, written once. BibTeX and
352
376
  // array-form YAML/JSON both re-render canonically (sync owns the output);
@@ -405,5 +429,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
405
429
  else warnings.push(`${file}: ${res.message}`)
406
430
  }
407
431
 
408
- return { updated, unchanged, deferred, warnings }
432
+ for (const file of ownIdFiles) {
433
+ if (!updated.includes(file) && !unchanged.includes(file)) unchanged.push(file)
434
+ }
435
+ return { updated, unchanged, deferred, warnings, mapped }
409
436
  }
package/src/uwx/index.js CHANGED
@@ -44,7 +44,6 @@ export {
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
46
  writeSiteOrg,
47
- writeSiteBackend,
48
47
  extensionDeclaration,
49
48
  isExtensionUrl,
50
49
  isSiteRelativeExtensionUrl,
@@ -77,14 +76,27 @@ export {
77
76
  localeFilePath,
78
77
  } from './locale-sync.js'
79
78
  export { emitSyncPackages } from './sync-package.js'
80
- export {
81
- readAssetMap,
82
- updateAssetMap,
79
+ // ⚠️ The asset MAP is `sync.json::backends.<origin>.assets` (sync-store.js, below).
80
+ // `readAssetMap` / `updateAssetMap` / `refForAssetId` / `ASSET_MAP_FILE` were removed
81
+ // on 2026-09-20: a single flat file cannot hold ids minted by more than one backend.
82
+ export { restoreAssetRefs, servedFingerprint } from './asset-map.js'
83
+ // `sync.json` — what each backend minted, keyed by origin. Absorbs `assets.json`
84
+ // above, `site.yml`'s identity keys and three maps out of the sync cache.
85
+ // Spec: kb/framework/reference/sync-json.md
86
+ export {
87
+ SYNC_STORE_FILE,
88
+ readSyncStore,
89
+ readBackendState,
90
+ listSyncedBackends,
91
+ updateBackendState,
92
+ updateBackendMap,
93
+ carryServed,
94
+ clearBackend,
95
+ clearBackendSections,
96
+ forgetSyncStore,
83
97
  refForAssetId,
84
- restoreAssetRefs,
85
- ASSET_MAP_FILE,
86
- servedFingerprint,
87
- } from './asset-map.js'
98
+ normalizeOrigin as normalizeBackendOrigin,
99
+ } from './sync-store.js'
88
100
  export {
89
101
  diffSiteUnits,
90
102
  describeSiteDiff,
@@ -32,6 +32,7 @@
32
32
  // nothing is written into `collections.yml` here. Nothing is silently dropped: an
33
33
  // unplaceable or unresolvable record is reported.
34
34
 
35
+ import { readBackendState, updateBackendMap } from './sync-store.js'
35
36
  import { readFileSync, readdirSync, existsSync } from 'node:fs'
36
37
  import { join, resolve, relative, extname, basename, sep } from 'node:path'
37
38
  import yaml from 'js-yaml'
@@ -175,13 +176,13 @@ function locate(document, folderIndex) {
175
176
  }
176
177
 
177
178
  /** `site.yml::$org`, bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
178
- function readSiteOrg(siteRoot) {
179
- try {
180
- const y = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
181
- return typeof y.$org === 'string' && y.$org ? y.$org : null
182
- } catch {
183
- return null
184
- }
179
+ // ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
180
+ // `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
181
+ // moved the key, silently returned null: every `@org/x` model a pull met would have
182
+ // been placed back as if the site had no org. No test spanned it.
183
+ function readSiteOrg(siteRoot, backend) {
184
+ const org = backend ? readBackendState(siteRoot, backend).site?.org : null
185
+ return typeof org === 'string' && org ? org : null
185
186
  }
186
187
 
187
188
  // Skip undefined when copying optional fields into a projected declaration.
@@ -335,7 +336,7 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
335
336
  * the same default `recordsToProject` places records by.
336
337
  * @returns {{ collections?: 'updated'|'unchanged' }}
337
338
  */
338
- export function declarationsToQueriesYml({ document, siteRoot, org }) {
339
+ export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
339
340
  const decls = Array.isArray(document?.queries) ? document.queries : []
340
341
  const report = {}
341
342
  if (decls.length === 0) return report
@@ -353,7 +354,7 @@ export function declarationsToQueriesYml({ document, siteRoot, org }) {
353
354
  siteYml = null
354
355
  }
355
356
  const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
356
- const selfOrg = org ?? readSiteOrg(siteRoot)
357
+ const selfOrg = org ?? readSiteOrg(siteRoot, backend)
357
358
 
358
359
  const queries = {}
359
360
  for (const d of decls) {
@@ -453,7 +454,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
453
454
  // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
454
455
  // placed back where the author wrote it. Read from `site.yml::$org` unless the
455
456
  // caller already has it.
456
- const selfOrg = opts.org ?? readSiteOrg(siteRoot)
457
+ const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
457
458
  if (typeof resolveDeclaration !== 'function') {
458
459
  throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
459
460
  }
@@ -468,6 +469,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
468
469
  const unchanged = []
469
470
  const skipped = []
470
471
  const warnings = []
472
+ // This backend's record map, inverted: its uuid → the record's own id.
473
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
474
+ const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
475
+ // own id → their uuid, for every record this pull wrote. Recorded at the end.
476
+ const learned = {}
471
477
  // uuid → the path under `entities/` the record landed at. Only this loop knows
472
478
  // the extension each one got, so `records.yml` is written from it rather than
473
479
  // re-derived (a second rule could pick a different extension and the folder
@@ -495,7 +501,13 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
495
501
  })
496
502
  continue
497
503
  }
498
- const existing = document.$uuid ? findRecordFileByUuid(poolDir, document.$uuid) : null
504
+ // ⭐ The incoming `$uuid` is the BACKEND's. The file holds the record's OWN id, so
505
+ // reverse this backend's map to find it. With no mapping the two are the same —
506
+ // the first backend a record reaches minted the id it keeps — so the backend's
507
+ // uuid is the own id, exactly as before identity was keyed by backend.
508
+ const theirs = document.$uuid || null
509
+ const own = theirs ? ownIdByTheirs.get(theirs) || theirs : null
510
+ const existing = own ? findRecordFileByUuid(poolDir, own) : null
499
511
 
500
512
  let filePath
501
513
  let format
@@ -514,9 +526,12 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
514
526
  // target-locale full-doc body is written under locales/freeform/{locale}/here.
515
527
  const freeformRelPath = buildFreeformRecordPath(document.$model, where.slug)
516
528
 
529
+ // ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
530
+ // backend must not overwrite the identity the record already has.
531
+ const toWrite = own && own !== theirs ? { ...document, $uuid: own } : document
517
532
  let status
518
533
  try {
519
- status = writeRecordFile({ filePath, document, declaration, format, sourceLocale, collector, freeformRelPath })
534
+ status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
520
535
  } catch (err) {
521
536
  warnings.push(`${where.slug}: ${err.message}`)
522
537
  continue
@@ -524,8 +539,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
524
539
  if (status === 'unchanged') unchanged.push(filePath)
525
540
  else if (isNew) placed.push(filePath)
526
541
  else updated.push(filePath)
527
- if (document.$uuid) {
528
- poolPathByUuid.set(document.$uuid, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
542
+ // Keyed by THEIR uuid: the folder document references records in the backend's
543
+ // terms, so that is what `folderToRecordsYml` will look them up by.
544
+ if (theirs) {
545
+ poolPathByUuid.set(theirs, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
546
+ if (own) learned[own] = theirs
529
547
  }
530
548
  }
531
549
 
@@ -543,5 +561,9 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
543
561
  const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
544
562
  const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
545
563
 
564
+ if (opts.backend && Object.keys(learned).length) {
565
+ updateBackendMap(siteRoot, opts.backend, 'records', learned)
566
+ }
567
+
546
568
  return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
547
569
  }
@@ -39,6 +39,7 @@
39
39
  // injected `opts.resolveModel`. A scope note that under-claims is worse than none:
40
40
  // it sends a reader to build what is already there.
41
41
 
42
+ import { readBackendState } from './sync-store.js'
42
43
  import { readFileSync, existsSync } from 'node:fs'
43
44
  import yaml from 'js-yaml'
44
45
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
@@ -740,9 +741,30 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
740
741
  }
741
742
  }
742
743
 
744
+ // ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
745
+ //
746
+ // A record's `$uuid` is its stable identity and travels with the file, so moving
747
+ // or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
748
+ // minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
749
+ // mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
750
+ // accept one is the backend's to say, and this is correct either way.
751
+ //
752
+ // For the FIRST backend a record reaches, B's minted uuid is written into the
753
+ // file and the map is identity — exactly what happened before this change, so a
754
+ // single-backend project behaves as it always did.
755
+ const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
756
+ const ownIds = new Map()
757
+ const onWire = flat.map((rec) => {
758
+ const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
759
+ ownIds.set(rec.slug, own)
760
+ const minted = own ? recordMap[own] : undefined
761
+ const { $uuid: _own, ...rest } = rec
762
+ return minted ? { ...rest, $uuid: minted } : rest
763
+ })
764
+
743
765
  const mappedOut = recordsToEntities({
744
766
  queryName: name,
745
- records: flat,
767
+ records: onWire,
746
768
  declaration,
747
769
  sourceLocale,
748
770
  translations,
@@ -780,6 +802,9 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
780
802
  // per-entry `$uuid` write keyed by slug (see backfill.js). `format` lets
781
803
  // the writer route (array-form vs BibTeX, the latter still deferred).
782
804
  sourceFile: src ? src.sourceFile : null,
805
+ // The record's OWN id (the file's `$uuid`), or null for one never synced.
806
+ // The back-fill maps it to what the backend returns; see above.
807
+ ownId: ownIds.get(e.slug) ?? null,
783
808
  format: src ? src.format : null,
784
809
  multiRecord: src ? src.multiRecord : false,
785
810
  // The Model declaration, so the back-fill can render the finalized
@@ -33,7 +33,8 @@
33
33
  // the i18n pipeline). Absent `info` keys are left untouched on disk.
34
34
 
35
35
  import { join, relative, extname, basename, dirname } from 'node:path'
36
- import { readAssetMap, restoreAssetRefs } from './asset-map.js'
36
+ import { restoreAssetRefs } from './asset-map.js'
37
+ import { readBackendState, updateBackendState } from './sync-store.js'
37
38
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync, mkdirSync } from 'node:fs'
38
39
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
39
40
  import { createHash } from 'node:crypto'
@@ -225,7 +226,7 @@ function readAuthoredYaml(filePath) {
225
226
  * @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
226
227
  * write status ('updated' | 'unchanged')
227
228
  */
228
- export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
229
+ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
229
230
  const info = document?.info || {}
230
231
  const settingsSection = document?.settings || {}
231
232
 
@@ -295,7 +296,7 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
295
296
  : []
296
297
  if (extensions.length > 0) siteChanges.extensions = extensions
297
298
 
298
- // services[] → site.yml::$services · secrets[] → site.yml::$secrets.
299
+ // services[] / secrets[] → sync.json::backends.<origin>.{services,secrets}.
299
300
  //
300
301
  // ⭐ The `$` prefix, and not the bare name, for the reason spelled out in
301
302
  // `uwx/site.js`: `site.yml::services` already means "pretend a host offers these"
@@ -315,10 +316,20 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
315
316
  // state — "this site has no service rows" — and it is the state a `pull` must be
316
317
  // able to deliver after the last one was removed. Skipping would leave a stale
317
318
  // `$services` on disk that the next push would resurrect.
318
- for (const [section, ymlKey] of [['services', '$services'], ['secrets', '$secrets']]) {
319
- const records = document?.[section]
320
- if (!Array.isArray(records)) continue
321
- siteChanges[ymlKey] = records.map(({ $id: _id, ...fields }) => fields)
319
+ // ⭐ To `sync.json`, under the backend that provisioned them — not `site.yml`.
320
+ // A service is bought on one backend, so its rows describe that backend alone.
321
+ // ⛔ Needs a backend: with none there is nowhere coherent to file them, and
322
+ // writing them to a shared place is exactly the single-copy bug this removed.
323
+ if (backend) {
324
+ const provisioned = {}
325
+ for (const section of ['services', 'secrets']) {
326
+ const records = document?.[section]
327
+ if (!Array.isArray(records)) continue
328
+ provisioned[section] = records.map(({ $id: _id, ...fields }) => fields)
329
+ }
330
+ if (Object.keys(provisioned).length) {
331
+ updateBackendState(siteRoot, backend, provisioned)
332
+ }
322
333
  }
323
334
 
324
335
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
@@ -859,7 +870,7 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
859
870
  * empty set.
860
871
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
861
872
  */
862
- export function siteContentDocumentToProject({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
873
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
863
874
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
864
875
 
865
876
  // Collects target-locale translations of localized scalars as they're projected;
@@ -871,10 +882,18 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
871
882
  // the author wrote. Without this a push/pull cycle rewrites every image in a
872
883
  // developer's source to a backend route — a mangling of files they own, by a
873
884
  // round trip that changed nothing.
874
- report.assets = restoreAssetRefs(document, readAssetMap(siteRoot))
875
-
876
- report.config = siteInfoToConfig({ document, siteRoot, sourceLocale, collector, keepAuthoredFoundation })
877
- report.queries = declarationsToQueriesYml({ document, siteRoot })
885
+ // ⛔ `backend` is required to restore anything, and its absence is HONEST rather
886
+ // than a default: asset ids are minted per backend (`sync.json`), so a projection
887
+ // not tied to one has no known assets and restores nothing. Every caller that
888
+ // pulls has an origin; the ones that do not are projecting a document in the
889
+ // abstract.
890
+ report.assets = restoreAssetRefs(
891
+ document,
892
+ backend ? readBackendState(siteRoot, backend).assets || {} : {}
893
+ )
894
+
895
+ report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
896
+ report.queries = declarationsToQueriesYml({ document, siteRoot, backend })
878
897
 
879
898
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
880
899
  // rename detection, build a fresh one as we project, then persist it. Items not