@uniweb/build 0.55.1 → 0.57.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 +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/entity-pool.js +136 -42
- package/src/site/index.js +2 -2
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +28 -149
- package/src/uwx/backfill.js +31 -4
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +29 -17
- package/src/uwx/records-project.js +131 -78
- package/src/uwx/records.js +324 -203
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +34 -15
- package/src/uwx/site.js +80 -90
- package/src/uwx/sync-package.js +23 -15
- package/src/uwx/sync-store.js +351 -0
- package/src/validate-data.js +3 -1
package/src/uwx/self-scope.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// (the foundation's scope, not the site's publish org) and stays there.
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* An org handle as `--org` or `
|
|
29
|
+
* An org handle as `--org` or `sync.json` gives it — `@acme`, `acme`, or
|
|
30
30
|
* `@acme/…` — reduced to the bare handle, or `''`.
|
|
31
31
|
*
|
|
32
32
|
* @param {unknown} org
|
|
@@ -56,15 +56,15 @@ export function resolveSelfScope(ref, org) {
|
|
|
56
56
|
* The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
|
|
57
57
|
*
|
|
58
58
|
* ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
|
|
59
|
-
* on both ends. A record authored under `
|
|
60
|
-
* `@acme/article` and, placed literally, lands under `
|
|
59
|
+
* on both ends. A record authored under `records/article/` comes back as
|
|
60
|
+
* `@acme/article` and, placed literally, lands under `records/acme/article/` — a
|
|
61
61
|
* different schema folder, which the next build reads as a different schema. A
|
|
62
62
|
* query declared `schema: '@/member'` comes back as `@acme/member`, and one that
|
|
63
63
|
* relied on the query-name default comes back with an explicit schema it never
|
|
64
64
|
* had.
|
|
65
65
|
*
|
|
66
|
-
* ⭐ The site records its own org at create (`site.
|
|
67
|
-
* which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
|
|
66
|
+
* ⭐ The site records its own org at create (`site.org` in `sync.json`, per backend —
|
|
67
|
+
* "whose this is"), which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
|
|
68
68
|
* genuinely is that org's, and `@/` would be a lie.
|
|
69
69
|
*
|
|
70
70
|
* @param {unknown} ref
|
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'
|
|
@@ -150,7 +151,7 @@ const INFO_TO_SITE_YML = {
|
|
|
150
151
|
// ⭐ `preview` — the card image, verbatim: the app's timestamp, an author's URL, or
|
|
151
152
|
// the path an author wrote for an image in the project, which `restoreAssetRefs`
|
|
152
153
|
// has already put back by recognizing the serve URL the push recorded in
|
|
153
|
-
// `
|
|
154
|
+
// `sync.json`'s asset map (asset-map.js). [Diego, 2026-09-10]
|
|
154
155
|
//
|
|
155
156
|
// ⚠️ It was listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
|
|
156
157
|
// 2026-09-10. Leaving an app-written field out of this map is what lets the next
|
|
@@ -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) }
|
|
@@ -338,8 +349,8 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
|
|
|
338
349
|
// `data_bundle` and `foundation_schema`. It had been a build-derived upload
|
|
339
350
|
// manifest that a pull deliberately never wrote back; framework never populated
|
|
340
351
|
// it. Nothing to project, and nothing here to remove — the note is kept because
|
|
341
|
-
//
|
|
342
|
-
// two get confused.
|
|
352
|
+
// the committed local path → id map (each backend's `assets` in `sync.json`;
|
|
353
|
+
// `assets.json` until 2026-09-20) is a DIFFERENT thing and the two get confused.
|
|
343
354
|
|
|
344
355
|
return result
|
|
345
356
|
}
|
|
@@ -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
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
// carries the verbatim `site.yml::foundation` string (the round-trip source of
|
|
15
15
|
// truth).
|
|
16
16
|
//
|
|
17
|
-
// IDENTITY. The ENTITY `$uuid`
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// IDENTITY. The ENTITY `$uuid` is a backend's, recorded per backend in `sync.json`
|
|
18
|
+
// (`backends.<origin>.site.uuid`): we read the one for the backend a document is
|
|
19
|
+
// produced FOR, send it, and record the minted value there. Nested pages/sections
|
|
20
|
+
// carry a `$id` handle AND a per-item `$uuid`. ⚠️ The entity `$uuid` lived in
|
|
21
|
+
// `site.yml` until 2026-09-20 — one value, with no way to say which backend minted it.
|
|
20
22
|
//
|
|
21
23
|
// The per-item uuid is NOT authored — author files never carry sync uuids. It is
|
|
22
|
-
// stamped at emit (`stampUnitUuids`) from
|
|
23
|
-
// the backend last reported: a pull, or a push response's
|
|
24
|
+
// stamped at emit (`stampUnitUuids`) from that backend's `items` map in `sync.json`,
|
|
25
|
+
// written from whatever the backend last reported: a pull, or a push response's
|
|
26
|
+
// `finalized[].document`. (The map sat in the gitignored sync cache until 2026-09-20.)
|
|
24
27
|
// This is load-bearing, not bookkeeping: the backend matches records by uuid, and
|
|
25
28
|
// `pages` / `page_sections` / `layout_sections` are all `multi` sections, where a
|
|
26
29
|
// uuid-less record is read as NEW — inserted, with its stored counterpart deleted as
|
|
@@ -67,6 +70,7 @@ import { emitEntitySyncPackage } from './entity-document.js'
|
|
|
67
70
|
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
68
71
|
import { unwrapLocalized } from './backfill.js'
|
|
69
72
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
73
|
+
import { updateBackendState, readBackendState } from './sync-store.js'
|
|
70
74
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
71
75
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
72
76
|
import { resolveSelfScope } from './self-scope.js'
|
|
@@ -384,7 +388,7 @@ const CATCH_ALL_MARKER = '...path'
|
|
|
384
388
|
// `page_sections`). Cross-section parentage is pure structure, never `$parent`.
|
|
385
389
|
// - `$id` (the stableId — the in-file handle) rides at every item level as the
|
|
386
390
|
// wire-only closure handle. Per-item `$uuid` is NOT authored here: it is
|
|
387
|
-
// stamped on by `stampUnitUuids` at emit, from the
|
|
391
|
+
// stamped on by `stampUnitUuids` at emit, from the backend's `items` map in `sync.json`,
|
|
388
392
|
// because the backend matches records by uuid and a uuid-less record in a
|
|
389
393
|
// `multi` section is read as new (inserted, stored counterpart deleted). See
|
|
390
394
|
// the IDENTITY note in the file header and `site-diff.js`.
|
|
@@ -400,8 +404,8 @@ const SITE_MODEL_NAME = '@uniweb/site-content'
|
|
|
400
404
|
// `fields` already carries `stable_id` (the Model field); `$id` is the same value.
|
|
401
405
|
// Both are kept: `$id` is the sync handle, `stable_id` is the declared content field
|
|
402
406
|
// the editor/render reads. Per-item `$uuid` is added later by `stampUnitUuids` (it
|
|
403
|
-
// comes from
|
|
404
|
-
//
|
|
407
|
+
// comes from `sync.json`, not from the authored files) — see the IDENTITY note in
|
|
408
|
+
// the file header.
|
|
405
409
|
function withIdentity(id, fields) {
|
|
406
410
|
return Object.assign({ $id: id }, fields)
|
|
407
411
|
}
|
|
@@ -974,8 +978,14 @@ function queriesNested(declarations, uuids = null, org = null) {
|
|
|
974
978
|
|
|
975
979
|
// ── `services` + `secrets` — a site's own service records ─────────────────────
|
|
976
980
|
//
|
|
977
|
-
//
|
|
978
|
-
//
|
|
981
|
+
// ⚠️ THESE WERE `site.yml::$services` / `$secrets` UNTIL 2026-09-20. They now live in
|
|
982
|
+
// `sync.json::backends.<origin>.{services,secrets}`, because what a site is PROVISIONED
|
|
983
|
+
// with is a fact about one backend — a service is bought on the backend that sold it.
|
|
984
|
+
// The `$` prefix existed to keep them off `site.yml::services`, which means something
|
|
985
|
+
// else on the bundle lane (the HOST tier); out of `site.yml` entirely, that collision
|
|
986
|
+
// is gone too.
|
|
987
|
+
//
|
|
988
|
+
// The original note, kept because it explains why the names could not simply be reused: `site.yml::services` is ALREADY TAKEN,
|
|
979
989
|
// on the other lane: the bundle lane spreads site.yml whole into the payload, so a
|
|
980
990
|
// `services:` block there lands at `config.services` — the HOST tier — which is the
|
|
981
991
|
// documented way to simulate a host locally (`kit/src/utils/submitTarget.js`).
|
|
@@ -1056,9 +1066,9 @@ function serviceRecords(declared, identify, label) {
|
|
|
1056
1066
|
}
|
|
1057
1067
|
|
|
1058
1068
|
/** One service per `name` — the same keyspace `config.services` uses at runtime. */
|
|
1059
|
-
function servicesNested(
|
|
1069
|
+
function servicesNested(provisioned) {
|
|
1060
1070
|
return serviceRecords(
|
|
1061
|
-
|
|
1071
|
+
provisioned?.services,
|
|
1062
1072
|
(e) => (typeof e.name === 'string' && e.name ? e.name : null),
|
|
1063
1073
|
'$services'
|
|
1064
1074
|
)
|
|
@@ -1076,9 +1086,9 @@ function servicesNested(siteYml) {
|
|
|
1076
1086
|
* judgement belongs; framework does not strip it, because silently dropping a value
|
|
1077
1087
|
* an author typed would leave them believing a secret was set.
|
|
1078
1088
|
*/
|
|
1079
|
-
function secretsNested(
|
|
1089
|
+
function secretsNested(provisioned) {
|
|
1080
1090
|
return serviceRecords(
|
|
1081
|
-
|
|
1091
|
+
provisioned?.secrets,
|
|
1082
1092
|
(e) => {
|
|
1083
1093
|
if (typeof e.name !== 'string' || !e.name) return null
|
|
1084
1094
|
return typeof e.service === 'string' && e.service
|
|
@@ -1207,13 +1217,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1207
1217
|
/**
|
|
1208
1218
|
* Map a file site project to the nested `@uniweb/site-content` `$`-document
|
|
1209
1219
|
* (see the lane header above). PURE — reads the project, never mints, never writes.
|
|
1210
|
-
* The entity `$uuid`
|
|
1211
|
-
* nested items carry `$id` only.
|
|
1220
|
+
* The entity `$uuid` is `opts.backend`'s, from `sync.json` (recorded after the first
|
|
1221
|
+
* sync to it); nested items carry `$id` only.
|
|
1212
1222
|
*
|
|
1213
1223
|
* @param {string} siteRoot - directory containing site.yml
|
|
1214
1224
|
* @param {object} [opts]
|
|
1225
|
+
* @param {string} [opts.backend] - whose `$uuid` to put on the wire (sync.json)
|
|
1215
1226
|
* @param {string} [opts.entityUuid] - override the entity `$uuid` (tests); default
|
|
1216
|
-
* is `site
|
|
1227
|
+
* is `opts.backend`'s site uuid in `sync.json` (absent on a first sync to it,
|
|
1228
|
+
* or with no backend — a `$id`-only document).
|
|
1217
1229
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
|
|
1218
1230
|
* the site's effective default locale (`defaultLanguage || languages[0] ||
|
|
1219
1231
|
* 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
|
|
@@ -1300,9 +1312,10 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1300
1312
|
// deploy-derived `info` field framework sends is `foundation`, via `injectInfo`
|
|
1301
1313
|
// in `publish`.
|
|
1302
1314
|
//
|
|
1303
|
-
// ⚠️ The note stays because
|
|
1304
|
-
//
|
|
1305
|
-
// thing with a similar name, and
|
|
1315
|
+
// ⚠️ The note stays because the COMMITTED local map from an author's asset path
|
|
1316
|
+
// to the backend's content-addressed id — each backend's `assets` in `sync.json`,
|
|
1317
|
+
// `assets.json` until 2026-09-20 — is a different thing with a similar name, and
|
|
1318
|
+
// it is very much alive.
|
|
1306
1319
|
setIf(info, 'favicon', siteYml.favicon)
|
|
1307
1320
|
// Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
|
|
1308
1321
|
// top-level `keywords`, hoisted to the site root so the homepage social card
|
|
@@ -1413,7 +1426,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1413
1426
|
// a site-root path to an image in the project, which push uploads like any
|
|
1414
1427
|
// content image and sends as its serve URL, and which pull puts back as the
|
|
1415
1428
|
// path the author wrote (`restoreAssetRefs` recognizes the URL by the
|
|
1416
|
-
// fingerprint `
|
|
1429
|
+
// fingerprint recorded for it in `sync.json`'s asset map).
|
|
1417
1430
|
// The app leaves an author's value alone.
|
|
1418
1431
|
//
|
|
1419
1432
|
// ⚠️ Until 2026-09-10 framework emitted no preview, on the premise that the host
|
|
@@ -1494,11 +1507,15 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1494
1507
|
// config (the records themselves are separate entities; this is just the config).
|
|
1495
1508
|
const colConfig = await resolveQueriesConfig(siteRoot, { siteYml })
|
|
1496
1509
|
|
|
1497
|
-
// `$uuid?` then `$id` `$model`, then sections in Model-declared order.
|
|
1498
|
-
// `$uuid` lives in site.yml (back-filled after first sync); absent on first sync.
|
|
1510
|
+
// `$uuid?` then `$id` `$model`, then sections in Model-declared order.
|
|
1499
1511
|
const doc = {}
|
|
1512
|
+
// ⭐ From `sync.json`, keyed by the backend this document is being produced FOR.
|
|
1513
|
+
// It was `site.yml::$uuid` — one scalar with no way to say which backend minted it.
|
|
1514
|
+
// A producer with no backend emits no `$uuid`, which is exactly right: the wire
|
|
1515
|
+
// value is that backend's name for this site, and there is no such name yet.
|
|
1500
1516
|
const entityUuid =
|
|
1501
|
-
opts.entityUuid ||
|
|
1517
|
+
opts.entityUuid ||
|
|
1518
|
+
(opts.backend ? readBackendState(siteRoot, opts.backend).site?.uuid : undefined)
|
|
1502
1519
|
if (entityUuid) doc.$uuid = entityUuid
|
|
1503
1520
|
doc.$id = SITE_ENTITY_KEY // one site-content entity per project (stable handle)
|
|
1504
1521
|
doc.$model = SITE_MODEL_NAME
|
|
@@ -1537,9 +1554,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1537
1554
|
// ⚖️ Default is to declare, so every existing caller is unchanged and the
|
|
1538
1555
|
// withholding is opt-in.
|
|
1539
1556
|
const declare = opts.declareServices !== false
|
|
1540
|
-
|
|
1557
|
+
// ⭐ Provisioned per backend — a service is bought on the backend that sold it,
|
|
1558
|
+
// so which rows exist is a question about ONE backend. They were `site.yml::$services`
|
|
1559
|
+
// / `$secrets`, a single copy that could only ever describe one.
|
|
1560
|
+
const provisioned = opts.backend ? readBackendState(siteRoot, opts.backend) : null
|
|
1561
|
+
const services = declare ? servicesNested(provisioned) : undefined
|
|
1541
1562
|
if (services) doc.services = services
|
|
1542
|
-
const secrets = declare ? secretsNested(
|
|
1563
|
+
const secrets = declare ? secretsNested(provisioned) : undefined
|
|
1543
1564
|
if (secrets) doc.secrets = secrets
|
|
1544
1565
|
return doc
|
|
1545
1566
|
}
|
|
@@ -1576,25 +1597,30 @@ export async function emitSiteSyncPackage(siteRoot, opts = {}) {
|
|
|
1576
1597
|
// Identity back-fill.
|
|
1577
1598
|
//
|
|
1578
1599
|
// The ENTITY `$uuid` (the backend's identity for the whole site-content entity) is
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1600
|
+
// recorded after the first sync to a backend, in that backend's section of
|
|
1601
|
+
// `sync.json`, beside the org the site was created under. Per-item uuids round-trip
|
|
1602
|
+
// too, through the same section's `items` map — see the IDENTITY note in the file
|
|
1603
|
+
// header. ⚠️ Until 2026-09-21 this note put the entity `$uuid` in `site.yml`, where it
|
|
1604
|
+
// lived until 2026-09-20, and said nested items have no per-item uuid round-trip,
|
|
1605
|
+
// which the file header says is not so.
|
|
1583
1606
|
// ===========================================================================
|
|
1584
1607
|
|
|
1585
1608
|
/**
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1609
|
+
* Record the minted site-content entity `$uuid` as `backend`'s site, in `sync.json`
|
|
1610
|
+
* (`backends.<origin>.site.uuid`). Until 2026-09-20 it was back-filled into
|
|
1611
|
+
* `site.yml` as a top-level `$uuid`.
|
|
1588
1612
|
* @param {string} siteRoot
|
|
1613
|
+
* @param {string} backend - the origin of the backend that minted it
|
|
1589
1614
|
* @param {string} uuid - the entity uuid the backend minted/echoed
|
|
1590
|
-
* @returns {boolean} true if
|
|
1615
|
+
* @returns {boolean} true if sync.json changed
|
|
1591
1616
|
*/
|
|
1592
|
-
export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
1593
|
-
return
|
|
1617
|
+
export function writeSiteEntityUuid(siteRoot, backend, uuid) {
|
|
1618
|
+
return updateBackendState(siteRoot, backend, { site: { uuid } })
|
|
1594
1619
|
}
|
|
1595
1620
|
|
|
1596
1621
|
/**
|
|
1597
|
-
* Record the org the site was CREATED under
|
|
1622
|
+
* Record the org the site was CREATED under — `backend`'s `site.org` in `sync.json`,
|
|
1623
|
+
* beside the site's uuid. (It was `site.yml::$org` until 2026-09-20.)
|
|
1598
1624
|
*
|
|
1599
1625
|
* The org is consumed at exactly one moment — the `as_org` on the create that
|
|
1600
1626
|
* mints `$uuid` — and after that the uuid carries the ownership binding. So this
|
|
@@ -1602,15 +1628,11 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
|
1602
1628
|
* this site's storage charged to?"*, which `$uuid` alone cannot answer and which
|
|
1603
1629
|
* otherwise costs a backend round-trip (or is simply unknowable from the repo).
|
|
1604
1630
|
*
|
|
1605
|
-
* Stored as the BARE handle, never `@handle
|
|
1606
|
-
*
|
|
1607
|
-
*
|
|
1608
|
-
*
|
|
1609
|
-
*
|
|
1610
|
-
* reader re-adds.
|
|
1611
|
-
*
|
|
1612
|
-
* Safe to add to `site.yml` because the sync lane is an explicit allowlist
|
|
1613
|
-
* (`info.*` above is built key by key), so this never rides the wire.
|
|
1631
|
+
* Stored as the BARE handle, never `@handle` — the canonical form everywhere else
|
|
1632
|
+
* (`deriveScope` returns it, `createOrg` echoes it as `org.handle`,
|
|
1633
|
+
* `validateHandle` validates it); the `@` is display sugar the reader re-adds. In
|
|
1634
|
+
* `site.yml` it was also the only form that parsed: `@` is a reserved YAML
|
|
1635
|
+
* indicator, so `$org: @acme` is an error.
|
|
1614
1636
|
*
|
|
1615
1637
|
* ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
|
|
1616
1638
|
* two look like the same class of fact. Four reasons, and the first is the one that
|
|
@@ -1618,9 +1640,9 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
|
1618
1640
|
*
|
|
1619
1641
|
* 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
|
|
1620
1642
|
* so its shape says "this may vary per target." An org may not: one site has one
|
|
1621
|
-
* owning org, fixed at create and preserved on replace.
|
|
1622
|
-
*
|
|
1623
|
-
* encode a freedom that does not exist.
|
|
1643
|
+
* owning org, fixed at create and preserved on replace. The org is a property of
|
|
1644
|
+
* the site's uuid, and sits beside it in the same backend's section — filing it
|
|
1645
|
+
* under a target would encode a freedom that does not exist.
|
|
1624
1646
|
* 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
|
|
1625
1647
|
* content-lane create, and `clone` seeding an existing one) and **none of them
|
|
1626
1648
|
* write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
|
|
@@ -1633,51 +1655,19 @@ export function writeSiteEntityUuid(siteRoot, uuid) {
|
|
|
1633
1655
|
* ever being deployed. Ownership does not belong in a record of a deploy that
|
|
1634
1656
|
* may not have happened.
|
|
1635
1657
|
*
|
|
1636
|
-
* `backend` answers *where this ships*;
|
|
1658
|
+
* `backend` answers *where this ships*; `site.org` answers *whose this is*.
|
|
1637
1659
|
*
|
|
1638
1660
|
* @param {string} siteRoot
|
|
1661
|
+
* @param {string} backend - the origin of the backend the site was created on
|
|
1639
1662
|
* @param {string} handle - the bare org handle (no leading `@`)
|
|
1640
|
-
* @returns {boolean} true if
|
|
1663
|
+
* @returns {boolean} true if sync.json changed
|
|
1641
1664
|
*/
|
|
1642
|
-
export function writeSiteOrg(siteRoot, handle) {
|
|
1643
|
-
return
|
|
1665
|
+
export function writeSiteOrg(siteRoot, backend, handle) {
|
|
1666
|
+
return updateBackendState(siteRoot, backend, { site: { org: handle } })
|
|
1644
1667
|
}
|
|
1645
1668
|
|
|
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
|
-
}
|
|
1669
|
+
// ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
|
|
1670
|
+
// no longer a value recorded beside the uuid — it is the KEY the identity is stored
|
|
1671
|
+
// under (`sync.json::backends.<origin>`), so there is nothing to record and nothing
|
|
1672
|
+
// that can disagree with it. `kb/framework/reference/sync-json.md`.
|
|
1673
|
+
|
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 } : {}),
|
|
@@ -288,16 +290,20 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
288
290
|
const folder = buildFolderEntity({
|
|
289
291
|
recordEntities: col.entities,
|
|
290
292
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
291
|
-
// ⭐
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
293
|
+
// ⭐ Every record in the directory, at the top of the folder or in the
|
|
294
|
+
// sub-folder `records.yml` places it in — and NOTHING when there is no folder to
|
|
295
|
+
// state. ⛔ Gated here as well as by `declared`, because the builder sends any
|
|
296
|
+
// non-empty tree: a `records.yml` whose folders hold only records that resolved
|
|
297
|
+
// no schema is a tree of empty branches, and sending it would replace the
|
|
298
|
+
// backend's folder with them (measured on the `dynamic` template, 2026-09-21).
|
|
299
|
+
folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
|
|
300
|
+
// ⛔ Whether the file side HAS a folder to state (`records.js::sendsFolder`) —
|
|
301
|
+
// not whether it holds anything. A site with no records directory sends none
|
|
302
|
+
// and leaves the backend's alone; an empty directory sends an empty folder,
|
|
303
|
+
// which REMOVES (the CLI asks first). Compared as `=== true`: an absent value
|
|
304
|
+
// must read as "nothing to state", which is how a site with no records.yml once
|
|
305
|
+
// emitted a folder that would have emptied the backend's.
|
|
306
|
+
declared: col.sendFolder === true,
|
|
301
307
|
// Placement identity from the folder document a previous push returned.
|
|
302
308
|
// Absent on a first push — every item is genuinely new then. Its ABSENCE on a
|
|
303
309
|
// later push is what made `publish` after `push` fail: send-only-changed skips
|
|
@@ -306,9 +312,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
306
312
|
...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
|
|
307
313
|
})
|
|
308
314
|
// ⚠️ A placement that produced no entity is dropped by the builder rather than
|
|
309
|
-
// sent pointing at nothing — but it must still be SAID.
|
|
310
|
-
//
|
|
311
|
-
//
|
|
315
|
+
// sent pointing at nothing — but it must still be SAID. `buildRecordEntities`
|
|
316
|
+
// places only records it produced, so this is a guard on the pairing rather than
|
|
317
|
+
// an expected path.
|
|
312
318
|
if (folder?.warnings?.length) warnings.push(...folder.warnings)
|
|
313
319
|
|
|
314
320
|
// `queryUuids` — identity for the `queries` section, keyed by query
|
|
@@ -316,6 +322,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
316
322
|
const siteDoc = includeSite
|
|
317
323
|
? await siteProjectToDocument(siteRoot, {
|
|
318
324
|
sourceLocale,
|
|
325
|
+
// Whose $uuid belongs on this wire document — see siteProjectToDocument.
|
|
326
|
+
...(opts.backend ? { backend: opts.backend } : {}),
|
|
319
327
|
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
|
|
320
328
|
// ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
|
|
321
329
|
// must name the Model its records are stored under, and both are qualified
|
|
@@ -400,8 +408,8 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
400
408
|
// ⛔ No identity attrs on `info`: it is a Section whose fields the host declares,
|
|
401
409
|
// and `preview` is a real ASSET_SLOTS slot, so stamping would add
|
|
402
410
|
// `previewAssetId`/`previewAssetExt` — fields the host refuses. Its bare strings
|
|
403
|
-
// are still swapped for their serve URLs, which `
|
|
404
|
-
// by fingerprint (asset-map.js → `servedFingerprint`).
|
|
411
|
+
// are still swapped for their serve URLs, which the asset map in `sync.json`
|
|
412
|
+
// recognizes on pull by fingerprint (asset-map.js → `servedFingerprint`).
|
|
405
413
|
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
|
|
406
414
|
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
|
|
407
415
|
}
|