@uniweb/build 0.57.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
- // `@/x` — a Model ref in the site's own foundation scope — and its qualified form.
1
+ // `@/x` — a Model ref in the site's foundation's own scope — and its qualified form.
2
2
  //
3
3
  // `@/member` names a data schema in the foundation's own `schemas/`. It is
4
4
  // AUTHORING shorthand: a backend resolves Models by name and never mints one, so a
5
5
  // ref that leaves the CLI must be the name the Model was stored under,
6
- // `@<org>/member`. Everything that reads a ref off the wire matches it exactly —
6
+ // `@<scope>/member`. Everything that reads a ref off the wire matches it exactly —
7
7
  // the entity store at restore, and a records service answering a hosted page's
8
8
  // query.
9
9
  //
@@ -13,7 +13,7 @@
13
13
  // - a query's `schema` `site.js::queriesNested` (the `queries` Section)
14
14
  // - the author's spelling, on pull `records-project.js` (placement and declarations)
15
15
  //
16
- // ⛔ They must agree on one alias. When the query path shipped `@/member` verbatim
16
+ // ⛔ They must agree on one scope. When the query path shipped `@/member` verbatim
17
17
  // while the records beside it were qualified, the query named a Model its own
18
18
  // records were not stored under: a hosted page's question named a Model that does
19
19
  // not exist, the records service refused that key, and the section rendered
@@ -21,39 +21,52 @@
21
21
  // `block.dataError`, the console clean. Nothing on either side was malformed; the
22
22
  // two paths disagreed.
23
23
  //
24
- // Registering a foundation qualifies its declarations separately, from the
25
- // foundation's publish scope (`registry-package.js`). That is a different input
26
- // (the foundation's scope, not the site's publish org) and stays there.
24
+ // ⭐ THE SCOPE IS THE FOUNDATION'S — the one in its name (`@acme/marketing`), which is
25
+ // the scope `register` stored the foundation's own data schemas under
26
+ // (`registry-package.js`). ⛔ Until 2026-09-22 every path above qualified with the org
27
+ // that owns the SITE instead — a stand-in that holds only when one org owns both, and
28
+ // a foundation may be registered under any org its author belongs to [Diego]. A site
29
+ // owned by `@client` on a foundation registered as `@acme/fnd` shipped its records as
30
+ // `@client/member`, a Model `register` never stored; a personal site shipped
31
+ // `@/member` unresolved (both measured). `siteSelfScope` is where the scope comes from.
32
+
33
+ import { readFileSync } from 'node:fs'
34
+ import { join, resolve } from 'node:path'
35
+ import yaml from 'js-yaml'
36
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
37
+ import { detectFoundationType, parseCatalogRef } from '../site/foundation-ref.js'
38
+ import { readFoundationName } from '../schema.js'
39
+ import { splitFoundationName } from '../foundation-name.js'
27
40
 
28
41
  /**
29
- * An org handle as `--org` or `sync.json` gives it — `@acme`, `acme`, or
30
- * `@acme/…` — reduced to the bare handle, or `''`.
42
+ * A scope as it may be written — `@acme`, `acme`, or `@acme/…` — reduced to the bare
43
+ * handle, or `''`.
31
44
  *
32
- * @param {unknown} org
45
+ * @param {unknown} scope
33
46
  * @returns {string}
34
47
  */
35
- export function bareOrg(org) {
36
- return typeof org === 'string' ? org.replace(/^@/, '').replace(/\/.*$/, '') : ''
48
+ export function bareOrg(scope) {
49
+ return typeof scope === 'string' ? scope.replace(/^@/, '').replace(/\/.*$/, '') : ''
37
50
  }
38
51
 
39
52
  /**
40
- * `@/x` → `@<org>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
41
- * does `@/x` when no org is known — callers that ship an unresolved alias say so
53
+ * `@/x` → `@<scope>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
54
+ * does `@/x` when no scope is known — callers that ship an unresolved alias say so
42
55
  * themselves (`buildRecordEntities` warns per query).
43
56
  *
44
57
  * @param {unknown} ref
45
- * @param {unknown} org
58
+ * @param {unknown} scope - the site's foundation's scope (`siteSelfScope`)
46
59
  * @returns {unknown}
47
60
  */
48
- export function resolveSelfScope(ref, org) {
49
- const handle = bareOrg(org)
61
+ export function resolveSelfScope(ref, scope) {
62
+ const handle = bareOrg(scope)
50
63
  return typeof ref === 'string' && ref.startsWith('@/') && handle
51
64
  ? `@${handle}/${ref.slice(2)}`
52
65
  : ref
53
66
  }
54
67
 
55
68
  /**
56
- * The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
69
+ * The inverse, for a pull: `@<scope>/x` → `@/x` for the site's foundation's scope.
57
70
  *
58
71
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
59
72
  * on both ends. A record authored under `records/article/` comes back as
@@ -63,16 +76,90 @@ export function resolveSelfScope(ref, org) {
63
76
  * relied on the query-name default comes back with an explicit schema it never
64
77
  * had.
65
78
  *
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
- * genuinely is that org's, and `@/` would be a lie.
79
+ * A model scoped to ANY OTHER scope is left alone: it genuinely is that org's, and
80
+ * `@/` would be a lie.
69
81
  *
70
82
  * @param {unknown} ref
71
- * @param {unknown} org
83
+ * @param {unknown} scope - the scope the pulled records were qualified with
72
84
  * @returns {unknown}
73
85
  */
74
- export function unresolveSelfScope(ref, org) {
75
- const handle = bareOrg(org)
86
+ export function unresolveSelfScope(ref, scope) {
87
+ const handle = bareOrg(scope)
76
88
  if (typeof ref !== 'string' || !handle) return ref
77
89
  return ref.startsWith(`@${handle}/`) ? `@/${ref.slice(handle.length + 2)}` : ref
78
90
  }
91
+
92
+ /**
93
+ * The scope a site's `@/x` refs resolve into: its foundation's, `@org` — or null
94
+ * when it has none yet.
95
+ *
96
+ * - `foundation` given (a pull passes the pinned ref the pushed site carries) — a
97
+ * catalog ref's scope, or a local foundation's, resolved from that value.
98
+ * - else `foundationDir` — that local foundation's.
99
+ * - else `site.yml::foundation`, the same way.
100
+ *
101
+ * A local foundation's scope is the one in its name, read by the one rule
102
+ * (`readFoundationName`, the reader `register` and `push` use), so what a push pins
103
+ * and what it qualifies with cannot differ. A bare name has no scope until `register`
104
+ * gives it one; a URL foundation has none.
105
+ *
106
+ * ⚠️ A foundation whose name cannot be read (a leftover `uniweb.scope`, a `main.js`
107
+ * that fails to load) answers null here. `register` — which a push and a publish run
108
+ * first — says why; this only qualifies.
109
+ *
110
+ * @param {string} siteRoot
111
+ * @param {{ foundation?: unknown, foundationDir?: string }} [opts]
112
+ * @returns {Promise<string|null>}
113
+ */
114
+ export async function siteSelfScope(siteRoot, { foundation, foundationDir } = {}) {
115
+ if (foundation === undefined && foundationDir) return localFoundationScope(resolve(foundationDir))
116
+ const declared = foundation !== undefined ? foundation : readDeclaredFoundation(siteRoot)
117
+ const value = declared && typeof declared === 'object' ? declared.name : declared
118
+ if (typeof value !== 'string' || !value) return null
119
+ const ref = parseCatalogRef(value)
120
+ if (ref) return ref.scope
121
+ let info = null
122
+ try {
123
+ info = detectFoundationType(value, siteRoot)
124
+ } catch {
125
+ return null
126
+ }
127
+ return info?.type === 'local' && info.path ? localFoundationScope(info.path) : null
128
+ }
129
+
130
+ async function localFoundationScope(dir) {
131
+ try {
132
+ const { name } = await readFoundationName(dir)
133
+ return splitFoundationName(name).scope
134
+ } catch {
135
+ return null
136
+ }
137
+ }
138
+
139
+ function readDeclaredFoundation(siteRoot) {
140
+ try {
141
+ const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
142
+ return siteYml.foundation ?? null
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Refuse the retired `org` option.
150
+ *
151
+ * ⛔ It carried the org that owns the SITE, and qualified a site's `@/x` refs with it
152
+ * (until 2026-09-22). Ignoring it would be silent, so a caller still passing it is told
153
+ * what replaced it: the foundation's scope, derived — or `scope`, to state it.
154
+ *
155
+ * @param {object|undefined} opts
156
+ * @param {string} where - the function, for the message
157
+ */
158
+ export function refuseOrgOption(opts, where) {
159
+ if (opts && opts.org !== undefined) {
160
+ throw new Error(
161
+ `${where}: \`org\` is no longer read — a site's \`@/x\` refs resolve into its ` +
162
+ `foundation's scope (the one in the foundation's name). Pass \`scope\` to state it.`
163
+ )
164
+ }
165
+ }
@@ -868,9 +868,12 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
868
868
  * files that have no corresponding incoming item (git-pull-like). Off by
869
869
  * default; `uniweb pull` opts in. Guarded against wiping a level on an
870
870
  * empty set.
871
+ * @param {string|null} [params.scope] - the scope the push qualified a query's
872
+ * `@/x` schema with, so it is written back as `@/x` — see
873
+ * `declarationsToQueriesYml`, whose default it overrides.
871
874
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
872
875
  */
873
- export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
876
+ export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
874
877
  const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
875
878
 
876
879
  // Collects target-locale translations of localized scalars as they're projected;
@@ -893,7 +896,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
893
896
  )
894
897
 
895
898
  report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
896
- report.queries = declarationsToQueriesYml({ document, siteRoot, backend })
899
+ report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
897
900
 
898
901
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
899
902
  // rename detection, build a fresh one as we project, then persist it. Items not
package/src/uwx/site.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Map a file site project to one `@uniweb/site-content` entity, as the
2
- // section-keyed `-document (docs/reference/entity-content.md), then to a
2
+ // section-keyed `$`-document (docs/reference/entity-content.md), then to a
3
3
  // `subtype: entity` .uwx on the SYNC lane.
4
4
  //
5
5
  // SOURCE LAYER: the content-collector's return is flattened and lossy for
@@ -8,9 +8,13 @@
8
8
  // processMarkdownFile, ordering, mode detection) so those semantics stay
9
9
  // identical to a normal build — only the directory/mode/order *walk* is ours.
10
10
  //
11
- // The document mirrors the @uniweb/site-content Model: `info` (brief) · `pages`
12
- // (self-nesting; each page carries its `page_sections` as an inline field) ·
13
- // `layout_sections` · `extensions` · `queries`. `info.foundation`
11
+ // The document mirrors the @uniweb/site-content Model: `info` (brief) · `settings`
12
+ // · `pages` (self-nesting; each page carries its `page_sections` as an inline
13
+ // field) · `layout_sections` · `extensions` · `queries` · `services` · `secrets`.
14
+ // `settings` is emitted only when non-empty; `services` and `secrets` only when the
15
+ // backend being pushed to has that key in `sync.json` (absent is not empty — see
16
+ // below). `emit-surface.json` states which `site.yml` key lands in `info` and
17
+ // `settings`. `info.foundation`
14
18
  // carries the verbatim `site.yml::foundation` string (the round-trip source of
15
19
  // truth).
16
20
  //
@@ -73,7 +77,7 @@ import { loadFreeformTranslation } from '../i18n/freeform.js'
73
77
  import { updateBackendState, readBackendState } from './sync-store.js'
74
78
  import { upsertYamlScalar } from './yaml-upsert.js'
75
79
  import { resolveQueriesConfig } from './queries-config.js'
76
- import { resolveSelfScope } from './self-scope.js'
80
+ import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
77
81
  import { siteContentDirs } from './site-dirs.js'
78
82
 
79
83
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
@@ -915,7 +919,7 @@ function externalSource(d) {
915
919
  return source
916
920
  }
917
921
 
918
- function queriesNested(declarations, uuids = null, org = null) {
922
+ function queriesNested(declarations, uuids = null, scope = null) {
919
923
  const out = []
920
924
  for (const [name, d] of Object.entries(declarations)) {
921
925
  refuseUnder(d.where, `queries.${name}`)
@@ -926,13 +930,13 @@ function queriesNested(declarations, uuids = null, org = null) {
926
930
  // the runtime fetches it from (never the records service).
927
931
  const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
928
932
  setIf(data, 'source', source)
929
- // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME ORG AS THE RECORDS' `$model`
930
- // (`records.js::buildRecordEntities`). A consumer answers a query by matching
931
- // this name against the Models its records were stored under, so a verbatim
932
- // `@/member` beside records stored as `@org/member` names nothing: the query
933
- // resolves no Model and the page that binds it renders empty. The pull puts the
934
- // author's `@/` back (`records-project.js::declarationsToQueriesYml`).
935
- setIf(data, 'schema', resolveSelfScope(d.schema, org))
933
+ // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$model`
934
+ // (`records.js::buildRecordEntities`) — the site's foundation's. A consumer
935
+ // answers a query by matching this name against the Models its records were
936
+ // stored under, so a verbatim `@/member` beside records stored as `@acme/member`
937
+ // names nothing: the query resolves no Model and the page that binds it renders
938
+ // empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
939
+ setIf(data, 'schema', resolveSelfScope(d.schema, scope))
936
940
  setIf(data, 'sort', d.sort)
937
941
  // Legacy `filter:` is not synced — it is translated to `where` upstream
938
942
  // (the canonical predicate). No legacy fields on the wire.
@@ -1009,16 +1013,19 @@ function queriesNested(declarations, uuids = null, org = null) {
1009
1013
  // ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
1010
1014
  // REPLACED by what we send, so `[]` means "drop every stored config row" while a
1011
1015
  // missing key means "I am not telling you about this". A project that has never
1012
- // pulled has no `$services`, and its ordinary push must not read as a request to
1013
- // wipe a service the operator configured in the app. So: emit the Section only when
1014
- // the file declares the key. Clearing is available and explicit — `$services: []`.
1016
+ // pulled has no `services` for that backend, and its ordinary push must not read as
1017
+ // a request to wipe a service the operator configured in the app. So: emit the
1018
+ // Section only when that backend's entry in `sync.json` carries the key. Clearing is
1019
+ // available and explicit — `services: []` there. *(Both said `site.yml::$services`
1020
+ // until the move on 2026-09-20.)*
1015
1021
  //
1016
1022
  // ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
1017
1023
  // tokens live in a gitignored per-clone cache, so a fresh clone pushes
1018
1024
  // unconditionally. Correctness has to sit here.
1019
1025
 
1020
1026
  /**
1021
- * `$services` / `$secrets` → Section records, or undefined when the key is absent.
1027
+ * A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
1028
+ * records, or undefined when the key is absent.
1022
1029
  *
1023
1030
  * ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
1024
1031
  * `queriesNested` above. The field set belongs to the backend's Model, a service's
@@ -1027,7 +1034,8 @@ function queriesNested(declarations, uuids = null, org = null) {
1027
1034
  * would DESTROY whatever is stored under it on every push. Framework can enumerate
1028
1035
  * its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
1029
1036
  *
1030
- * @param {*} declared - the raw `$services` / `$secrets` value from site.yml
1037
+ * @param {*} declared - the raw `services` / `secrets` value from that backend's
1038
+ * `sync.json` entry
1031
1039
  * @param {(entry: object) => string|null} identify - the record's stable `$id`
1032
1040
  * @param {string} label - the key name, for the one warning below
1033
1041
  * @returns {object[]|undefined}
@@ -1229,13 +1237,16 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1229
1237
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
1230
1238
  * the site's effective default locale (`defaultLanguage || languages[0] ||
1231
1239
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
1232
- * @param {string} [opts.org] - the publish org, which qualifies a query's
1233
- * foundation-relative `schema` (`@/x` → `@org/x`). Pass the same org the
1234
- * records are emitted with; absent, `@/x` ships as written.
1240
+ * @param {string|null} [opts.scope] - the scope a query's foundation-relative `schema`
1241
+ * resolves into (`@/x` → `@scope/x`): the site's foundation's. Defaults to
1242
+ * `siteSelfScope`; pass the scope the records are emitted with. With none
1243
+ * known, `@/x` ships as written.
1235
1244
  * @returns {Promise<object>} the section-keyed `$`-document:
1236
- * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
1245
+ * `{ $uuid?, $id, $model, info, settings?, pages, layout_sections, extensions,
1246
+ * queries, services?, secrets? }`
1237
1247
  */
1238
1248
  export async function siteProjectToDocument(siteRoot, opts = {}) {
1249
+ refuseOrgOption(opts, 'uwx/site')
1239
1250
  const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
1240
1251
  const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
1241
1252
  if (!siteYml.name) {
@@ -1533,7 +1544,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1533
1544
  //
1534
1545
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1535
1546
  // consumer sees, leave the identifier alone.
1536
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, opts.org)
1547
+ const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
1548
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope)
1537
1549
  // Emitted ONLY when the file declares the key — see the header above
1538
1550
  // `serviceRecords`: on a replaced Section, absent and empty are different
1539
1551
  // requests and one of them is destructive.
@@ -1618,53 +1630,13 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
1618
1630
  return updateBackendState(siteRoot, backend, { site: { uuid } })
1619
1631
  }
1620
1632
 
1621
- /**
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.)
1624
- *
1625
- * The org is consumed at exactly one moment — the `as_org` on the create that
1626
- * mints `$uuid` — and after that the uuid carries the ownership binding. So this
1627
- * is not a knob the backend re-reads; it is the answer to *"whose workspace is
1628
- * this site's storage charged to?"*, which `$uuid` alone cannot answer and which
1629
- * otherwise costs a backend round-trip (or is simply unknowable from the repo).
1630
- *
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.
1636
- *
1637
- * ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
1638
- * two look like the same class of fact. Four reasons, and the first is the one that
1639
- * settles it:
1640
- *
1641
- * 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
1642
- * so its shape says "this may vary per target." An org may not: one site has one
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.
1646
- * 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
1647
- * content-lane create, and `clone` seeding an existing one) and **none of them
1648
- * write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
1649
- * record would exist or not depending on which verb the developer reached for.
1650
- * 3. SUPPRESSIBLE. `recordLastDeploy` is a no-op under `autoSave: off` / `--no-save`.
1651
- * Turning off deploy *receipts* would silently drop an *ownership* record.
1652
- * 4. SEMANTICS. `deploy.yml` describes the act of shipping (`lastDeploy` is a
1653
- * receipt) and is optional entirely. But `push` creates a site and never
1654
- * publishes — a site can exist, be owned, and accrue storage charges without
1655
- * ever being deployed. Ownership does not belong in a record of a deploy that
1656
- * may not have happened.
1657
- *
1658
- * `backend` answers *where this ships*; `site.org` answers *whose this is*.
1659
- *
1660
- * @param {string} siteRoot
1661
- * @param {string} backend - the origin of the backend the site was created on
1662
- * @param {string} handle - the bare org handle (no leading `@`)
1663
- * @returns {boolean} true if sync.json changed
1664
- */
1665
- export function writeSiteOrg(siteRoot, backend, handle) {
1666
- return updateBackendState(siteRoot, backend, { site: { org: handle } })
1667
- }
1633
+ // ⛔ `writeSiteOrg` is GONE (2026-09-23). The workspace a project's requests name is two
1634
+ // keys of `site` now — `org` (a handle, bare) and `unit` (the uuid of a workspace with no
1635
+ // handle), at most one set — and every site request names it, so it is no longer a note
1636
+ // written once at the create. A writer of one key would leave the other stale; write
1637
+ // both through `updateBackendState`. Why it lives here rather than in `deploy.yml`: a
1638
+ // site has one workspace per backend, fixed by the create, while `deploy.yml` is per
1639
+ // target and records the act of shipping, which `push` never does.
1668
1640
 
1669
1641
  // ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
1670
1642
  // no longer a value recorded beside the uuid — it is the KEY the identity is stored
@@ -23,6 +23,7 @@ import { buildRecordEntities, entityContentHash } from './records.js'
23
23
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
24
24
  import { buildFolderEntity } from './folder.js'
25
25
  import { siteProjectToDocument } from './site.js'
26
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
26
27
  import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
27
28
  import { emitEntitySyncPackage } from './entity-document.js'
28
29
  import { isLocalAssetPath } from '../site/assets.js'
@@ -205,6 +206,9 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
205
206
  * @param {string} siteRoot - directory containing site.yml
206
207
  * @param {object} [opts]
207
208
  * @param {string} [opts.foundationDir] - local foundation root (collection Models)
209
+ * @param {string|null} [opts.scope] - the scope the site's `@/x` refs resolve
210
+ * into. Defaults to its foundation's (`siteSelfScope`) — the one in the
211
+ * foundation's name. `org` is refused: it carried the site owner's.
208
212
  * @param {Function} [opts.resolveModel] - async non-local Model resolver
209
213
  * @param {string} [opts.sourceLocale] - localized-field wrap locale
210
214
  * @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
@@ -271,6 +275,13 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
271
275
  const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
272
276
  const exporter = opts.exporter
273
277
  const exportedAt = opts.exportedAt
278
+ refuseOrgOption(opts, 'emitSyncPackages')
279
+ // ⭐ ONE SCOPE FOR THE WHOLE EMIT — the site's foundation's, the one `register`
280
+ // stored its Models under — so a record's `$model` and a query's `schema` are
281
+ // qualified alike. Read once here and handed to both. ⛔ It was the org that owns
282
+ // the SITE until 2026-09-22 (`self-scope.js`).
283
+ const scope =
284
+ opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
274
285
 
275
286
  const col = await buildRecordEntities(siteRoot, {
276
287
  // Which backend's record uuids go on the wire (sync.json) — see records.js.
@@ -278,10 +289,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
278
289
  ...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
279
290
  ...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
280
291
  ...(sourceLocale ? { sourceLocale } : {}),
281
- // The publish org — resolves a foundation-relative `@/x` model ref into
282
- // `@org/x` before it ships. Absent on an offline probe, which is why
283
- // buildRecordEntities warns rather than throws.
284
- ...(opts.org ? { org: opts.org } : {}),
292
+ // Resolves a foundation-relative `@/x` model ref into `@scope/x` before it ships.
293
+ // Null for a foundation with no scope yet, which is why buildRecordEntities
294
+ // warns rather than throws.
295
+ scope,
285
296
  })
286
297
  const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
287
298
 
@@ -291,9 +302,9 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
291
302
  recordEntities: col.entities,
292
303
  ...(sourceLocale ? { sourceLocale } : {}),
293
304
  // ⭐ 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
305
+ // sub-folder `folder.yml` places it in — and NOTHING when there is no folder to
295
306
  // 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
307
+ // non-empty tree: a `folder.yml` whose folders hold only records that resolved
297
308
  // no schema is a tree of empty branches, and sending it would replace the
298
309
  // backend's folder with them (measured on the `dynamic` template, 2026-09-21).
299
310
  folderNodes: col.sendFolder === true ? (col.folder?.nodes ?? []) : [],
@@ -325,10 +336,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
325
336
  // Whose $uuid belongs on this wire document — see siteProjectToDocument.
326
337
  ...(opts.backend ? { backend: opts.backend } : {}),
327
338
  ...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
328
- // ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
339
+ // ⛔ THE SAME SCOPE `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
329
340
  // must name the Model its records are stored under, and both are qualified
330
- // from one `@/x` by one rule (`./self-scope.js`) — so they take one org.
331
- ...(opts.org ? { org: opts.org } : {}),
341
+ // from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
342
+ scope,
332
343
  // Withhold the `$services`/`$secrets` Sections when the caller has
333
344
  // determined the file is not asking for anything new by them. Passed
334
345
  // through rather than decided here: the last-agreed state is project