@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.
- package/package.json +8 -8
- 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/site/queries-config.js +1 -1
- 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/asset-map.js
CHANGED
|
@@ -1,156 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
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
|