@uniweb/build 0.55.1 → 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.
- package/package.json +3 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/index.js +2 -2
- package/src/uwx/asset-map.js +26 -148
- package/src/uwx/backfill.js +29 -2
- package/src/uwx/index.js +20 -8
- package/src/uwx/records-project.js +36 -14
- package/src/uwx/records.js +26 -1
- package/src/uwx/site-project.js +31 -12
- package/src/uwx/site.js +35 -51
- package/src/uwx/sync-package.js +4 -0
- package/src/uwx/sync-store.js +351 -0
package/src/uwx/backfill.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -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:
|
|
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
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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 {
|
|
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[]
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
package/src/uwx/site.js
CHANGED
|
@@ -67,6 +67,7 @@ import { emitEntitySyncPackage } from './entity-document.js'
|
|
|
67
67
|
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
68
68
|
import { unwrapLocalized } from './backfill.js'
|
|
69
69
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
70
|
+
import { updateBackendState, readBackendState } from './sync-store.js'
|
|
70
71
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
71
72
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
72
73
|
import { resolveSelfScope } from './self-scope.js'
|
|
@@ -974,8 +975,14 @@ function queriesNested(declarations, uuids = null, org = null) {
|
|
|
974
975
|
|
|
975
976
|
// ── `services` + `secrets` — a site's own service records ─────────────────────
|
|
976
977
|
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
978
|
+
// ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
|
|
979
|
+
// `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
|
|
980
|
+
// with is a fact about one backend — a service is bought on the backend that sold it.
|
|
981
|
+
// The `$` prefix existed to keep them off `site.yml::services`, which means something
|
|
982
|
+
// else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
|
|
983
|
+
// is gone too.
|
|
984
|
+
//
|
|
985
|
+
// The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
|
|
979
986
|
// on the other lane: the bundle lane spreads site.yml whole into the payload, so a
|
|
980
987
|
// `services:` block there lands at `config.services` — the HOST tier — which is the
|
|
981
988
|
// documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
|
|
@@ -1056,9 +1063,9 @@ function serviceRecords(declared, identify, label) {
|
|
|
1056
1063
|
}
|
|
1057
1064
|
|
|
1058
1065
|
/** One service per `name` — the same keyspace `config.services` uses at runtime. */
|
|
1059
|
-
function servicesNested(
|
|
1066
|
+
function servicesNested(provisioned) {
|
|
1060
1067
|
return serviceRecords(
|
|
1061
|
-
|
|
1068
|
+
provisioned?.services,
|
|
1062
1069
|
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
1063
1070
|
'$services'
|
|
1064
1071
|
)
|
|
@@ -1076,9 +1083,9 @@ function servicesNested(siteYml) {
|
|
|
1076
1083
|
* judgement belongs; framework does not strip it, because silently dropping a value
|
|
1077
1084
|
* an author typed would leave them believing a secret was set.
|
|
1078
1085
|
*/
|
|
1079
|
-
function secretsNested(
|
|
1086
|
+
function secretsNested(provisioned) {
|
|
1080
1087
|
return serviceRecords(
|
|
1081
|
-
|
|
1088
|
+
provisioned?.secrets,
|
|
1082
1089
|
(e) => {
|
|
1083
1090
|
if (typeof e.name !== 'string' || !e.name) return null
|
|
1084
1091
|
return typeof e.service === 'string' && e.service
|
|
@@ -1212,6 +1219,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1212
1219
|
*
|
|
1213
1220
|
* @param {string} siteRoot - directory containing site.yml
|
|
1214
1221
|
* @param {object} [opts]
|
|
1222
|
+
* @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
|
|
1215
1223
|
* @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
|
|
1216
1224
|
* is `site.yml::$uuid` (absent on first sync — `$id`-only document).
|
|
1217
1225
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
|
|
@@ -1497,8 +1505,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1497
1505
|
// `$uuid?` then `$id` `$model`, then sections in Model-declared order. The entity
|
|
1498
1506
|
// `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
|
|
1499
1507
|
const doc = {}
|
|
1508
|
+
// ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
|
|
1509
|
+
// It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
|
|
1510
|
+
// A producer with no backend emits no `$uuid`, which is exactly right: the wire
|
|
1511
|
+
// value is that backend's name for this site, and there is no such name yet.
|
|
1500
1512
|
const entityUuid =
|
|
1501
|
-
opts.entityUuid ||
|
|
1513
|
+
opts.entityUuid ||
|
|
1514
|
+
(opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
|
|
1502
1515
|
if (entityUuid) doc.$uuid = entityUuid
|
|
1503
1516
|
doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
|
|
1504
1517
|
doc.$model = SITE_MODEL_NAME
|
|
@@ -1537,9 +1550,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1537
1550
|
// ⚖️ Default is to declare, so every existing caller is unchanged and the
|
|
1538
1551
|
// withholding is opt-in.
|
|
1539
1552
|
const declare = opts.declareServices !== false
|
|
1540
|
-
|
|
1553
|
+
// ⭐ Provisioned per backend — a service is bought on the backend that sold it,
|
|
1554
|
+
// so which rows exist is a question about ONE backend. They were `site.yml::$services`
|
|
1555
|
+
// / `$secrets`, a single copy that could only ever describe one.
|
|
1556
|
+
const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
|
|
1557
|
+
const services = declare ? servicesNested(provisioned) : undefined
|
|
1541
1558
|
if (services) doc.services = services
|
|
1542
|
-
const secrets = declare ? secretsNested(
|
|
1559
|
+
const secrets = declare ? secretsNested(provisioned) : undefined
|
|
1543
1560
|
if (secrets) doc.secrets = secrets
|
|
1544
1561
|
return doc
|
|
1545
1562
|
}
|
|
@@ -1589,8 +1606,8 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
|
|
|
1589
1606
|
* @param {string} uuid - the entity uuid the backend minted/echoed
|
|
1590
1607
|
* @returns {boolean} true if site.yml changed
|
|
1591
1608
|
*/
|
|
1592
|
-
export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
1593
|
-
return
|
|
1609
|
+
export function writeSiteEntityUuid(siteRoot, backend, uuid) {
|
|
1610
|
+
return updateBackendState(siteRoot, backend, { site: { uuid } })
|
|
1594
1611
|
}
|
|
1595
1612
|
|
|
1596
1613
|
/**
|
|
@@ -1639,45 +1656,12 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
|
1639
1656
|
* @param {string} handle - the bare org handle (no leading `@`)
|
|
1640
1657
|
* @returns {boolean} true if site.yml changed
|
|
1641
1658
|
*/
|
|
1642
|
-
export function writeSiteOrg(siteRoot, handle) {
|
|
1643
|
-
return
|
|
1659
|
+
export function writeSiteOrg(siteRoot, backend, handle) {
|
|
1660
|
+
return updateBackendState(siteRoot, backend, { site: { org: handle } })
|
|
1644
1661
|
}
|
|
1645
1662
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
* `site.yml::$uuid`, every collection record's `$uuid` in its own source file,
|
|
1652
|
-
* `assets.json`, and `.uniweb/sync-cache.json`. Change the backend and every one of them
|
|
1653
|
-
* is foreign at once — which is why a mismatch is a stop, not a fallback
|
|
1654
|
-
* (`assertSiteBackendScope` in the CLI).
|
|
1655
|
-
*
|
|
1656
|
-
* ⛔ **Written ONLY for a non-default backend.** The 98% case stays out of the file, and an
|
|
1657
|
-
* absent value reads as the default — correct both for a project written before this key
|
|
1658
|
-
* existed and for one synced against the default. Callers decide; this writer does not know
|
|
1659
|
-
* the CLI's default. See `recordSiteBackend` (cli/src/backend/site-sync.js), which is where
|
|
1660
|
-
* the "is it the default?" test lives.
|
|
1661
|
-
*
|
|
1662
|
-
* ⚠️ **The name is load-bearing. Five alternatives were considered and rejected (2026-08-24);
|
|
1663
|
-
* do not re-propose one.** `origin` is wrong twice over — a site HAS an origin (its own
|
|
1664
|
-
* served domain), and this lane is git-modeled, where `origin` names a remote rather than a
|
|
1665
|
-
* URL. `remote` implies a multiplicity this design closes. `hosting` and `platform` name the
|
|
1666
|
-
* hosting edge, which this package has no client for. `host` collides with
|
|
1667
|
-
* `deploy.yml::targets.<n>.host` (the deploy adapter). A `uuid: <id>@<backend>` suffix was
|
|
1668
|
-
* rejected too: it would make "this never reaches the backend" a discipline at every read
|
|
1669
|
-
* site instead of a property, and `siteProjectToDocument` assigns `$uuid` straight onto the
|
|
1670
|
-
* wire document.
|
|
1671
|
-
*
|
|
1672
|
-
* Safe as a plain YAML scalar: a URL's `:` is followed by `/` or a digit, never a space, so
|
|
1673
|
-
* it needs no quoting (verified against js-yaml for apex, `localhost:8080` and host:port+path
|
|
1674
|
-
* forms, 2026-08-24). This is the same class of hazard as `$org`'s leading `@`, which is
|
|
1675
|
-
* stripped for exactly that reason — but it lands on the safe side.
|
|
1676
|
-
*
|
|
1677
|
-
* @param {string} siteRoot
|
|
1678
|
-
* @param {string} origin - a bare origin, no trailing slash
|
|
1679
|
-
* @returns {boolean} true if site.yml changed
|
|
1680
|
-
*/
|
|
1681
|
-
export function writeSiteBackend(siteRoot, origin) {
|
|
1682
|
-
return upsertYamlScalar(join(siteRoot, 'site.yml'), '$backend', origin)
|
|
1683
|
-
}
|
|
1663
|
+
// ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
|
|
1664
|
+
// no longer a value recorded beside the uuid — it is the KEY the identity is stored
|
|
1665
|
+
// under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
|
|
1666
|
+
// that can disagree with it. `kb/framework/reference/sync-json.md`.
|
|
1667
|
+
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -273,6 +273,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
273
273
|
const exportedAt = opts.exportedAt
|
|
274
274
|
|
|
275
275
|
const col = await buildRecordEntities(siteRoot, {
|
|
276
|
+
// Which backend's record uuids go on the wire (sync.json) — see records.js.
|
|
277
|
+
...(opts.backend ? { backend: opts.backend } : {}),
|
|
276
278
|
...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
|
|
277
279
|
...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
|
|
278
280
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
@@ -316,6 +318,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
316
318
|
const siteDoc = includeSite
|
|
317
319
|
? await siteProjectToDocument(siteRoot, {
|
|
318
320
|
sourceLocale,
|
|
321
|
+
// Whose $uuid belongs on this wire document — see siteProjectToDocument.
|
|
322
|
+
...(opts.backend ? { backend: opts.backend } : {}),
|
|
319
323
|
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
|
|
320
324
|
// ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
|
|
321
325
|
// must name the Model its records are stored under, and both are qualified
|