uniweb 0.33.1 → 0.34.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.
@@ -242,13 +242,13 @@ async function loadSiteConfig(siteRoot) {
242
242
  async function runExtract(siteRoot, config, args) {
243
243
  const verbose = args.includes('--verbose') || args.includes('-v')
244
244
  const dryRun = args.includes('--dry-run')
245
- const collectionsOnly =
246
- args.includes('--collections-only') || args.includes('--collections')
247
- const noCollections = args.includes('--no-collections')
245
+ const recordsOnly =
246
+ args.includes('--records-only') || args.includes('--records')
247
+ const noRecords = args.includes('--no-records')
248
248
  // --with-collections is now a no-op (collections are included by default)
249
249
 
250
- // Extract page content (unless --collections-only)
251
- if (!collectionsOnly) {
250
+ // Extract page content (unless --records-only)
251
+ if (!recordsOnly) {
252
252
  log(
253
253
  `\n${colors.cyan}Extracting translatable content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
254
254
  )
@@ -309,43 +309,43 @@ async function runExtract(siteRoot, config, args) {
309
309
  }
310
310
  }
311
311
 
312
- // Extract collection content (by default, skip with --no-collections)
313
- if (!noCollections) {
312
+ // Extract collection content (by default, skip with --no-records)
313
+ if (!noRecords) {
314
314
  log(
315
- `\n${colors.cyan}Extracting collection content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
315
+ `\n${colors.cyan}Extracting record content${dryRun ? ' (dry run)' : ''}...${colors.reset}\n`
316
316
  )
317
317
 
318
318
  // Check if collections exist
319
319
  const dataDir = join(siteRoot, 'public', DATA_DIR)
320
320
  if (!existsSync(dataDir)) {
321
- if (collectionsOnly) {
322
- error('No collections found. Create collection data in public/data/.')
321
+ if (recordsOnly) {
322
+ error('No records found. Add entities under entities/ and list them in records.yml.')
323
323
  process.exit(1)
324
324
  }
325
- log(`${colors.dim}No collections found in public/data/.${colors.reset}`)
325
+ log(`${colors.dim}No records found in public/data/.${colors.reset}`)
326
326
  return
327
327
  }
328
328
 
329
329
  try {
330
- const { extractCollectionManifest, formatSyncReport } =
330
+ const { extractRecordManifest, formatSyncReport } =
331
331
  await import('@uniweb/build/i18n')
332
332
 
333
- const collectionsManifestPath = join(
333
+ const recordManifestPath = join(
334
334
  siteRoot,
335
335
  config.localesDir,
336
- 'collections',
336
+ 'records',
337
337
  'manifest.json'
338
338
  )
339
- const isUpdate = existsSync(collectionsManifestPath)
339
+ const isUpdate = existsSync(recordManifestPath)
340
340
 
341
- const { manifest, report } = await extractCollectionManifest(siteRoot, {
341
+ const { manifest, report } = await extractRecordManifest(siteRoot, {
342
342
  localesDir: config.localesDir,
343
343
  dryRun
344
344
  })
345
345
 
346
346
  const unitCount = Object.keys(manifest.units).length
347
347
  if (unitCount > 0) {
348
- success(`Extracted ${unitCount} translatable strings from collections`)
348
+ success(`Extracted ${unitCount} translatable strings from records`)
349
349
 
350
350
  if (report && isUpdate) {
351
351
  log('')
@@ -356,18 +356,18 @@ async function runExtract(siteRoot, config, args) {
356
356
  log(`\n${colors.dim}Dry run — no files were modified.${colors.reset}`)
357
357
  } else {
358
358
  log(
359
- `\nManifest written to: ${colors.dim}${config.localesDir}/collections/manifest.json${colors.reset}`
359
+ `\nManifest written to: ${colors.dim}${config.localesDir}/records/manifest.json${colors.reset}`
360
360
  )
361
361
  }
362
362
  } else {
363
363
  log(
364
- `${colors.dim}No translatable content found in collections.${colors.reset}`
364
+ `${colors.dim}No translatable content found in records.${colors.reset}`
365
365
  )
366
366
  }
367
367
  } catch (err) {
368
- error(`Collection extraction failed: ${err.message}`)
368
+ error(`Record extraction failed: ${err.message}`)
369
369
  if (verbose) console.error(err)
370
- if (collectionsOnly) process.exit(1)
370
+ if (recordsOnly) process.exit(1)
371
371
  }
372
372
  }
373
373
  }
@@ -652,7 +652,7 @@ async function runStatusFreeform(siteRoot, config, locale, options = {}) {
652
652
  const allPaths = [
653
653
  ...discovered.pages,
654
654
  ...discovered.pageIds,
655
- ...discovered.collections
655
+ ...discovered.records
656
656
  ]
657
657
 
658
658
  // Check staleness
@@ -988,11 +988,11 @@ async function runAudit(siteRoot, config, args) {
988
988
  * Usage:
989
989
  * uniweb i18n init-freeform es pages/about hero
990
990
  * uniweb i18n init-freeform es page-ids/installation intro
991
- * uniweb i18n init-freeform es collections/articles getting-started
991
+ * uniweb i18n init-freeform es entities/article getting-started
992
992
  */
993
993
  async function runInitFreeform(siteRoot, config, args) {
994
994
  const locale = args[0]
995
- const pathType = args[1] // pages/about, page-ids/installation, collections/articles
995
+ const pathType = args[1] // pages/about, page-ids/installation, entities/article
996
996
  const sectionId = args[2] // hero, intro, getting-started
997
997
 
998
998
  if (!locale || !pathType || !sectionId) {
@@ -1001,7 +1001,7 @@ async function runInitFreeform(siteRoot, config, args) {
1001
1001
  log(' uniweb i18n init-freeform es pages/about hero')
1002
1002
  log(' uniweb i18n init-freeform es page-ids/installation intro')
1003
1003
  log(
1004
- ` uniweb i18n init-freeform es collections/articles getting-started${colors.reset}`
1004
+ ` uniweb i18n init-freeform es entities/article getting-started${colors.reset}`
1005
1005
  )
1006
1006
  process.exit(1)
1007
1007
  }
@@ -1057,15 +1057,29 @@ async function runInitFreeform(siteRoot, config, args) {
1057
1057
  if (sourceContent) break
1058
1058
  }
1059
1059
  }
1060
- } else if (pathType.startsWith('collections/')) {
1061
- // Find item in collection data
1062
- const collectionName = pathType.replace('collections/', '')
1063
- const dataPath = join(
1064
- siteRoot,
1065
- 'public',
1066
- 'data',
1067
- `${collectionName}.json`
1068
- )
1060
+ } else if (pathType.startsWith('entities/')) {
1061
+ // ADDRESSED BY THE RECORD, matching where the loader reads. The freeform
1062
+ // tree is `entities/<schema dirs>/<slug>.md`; this used to take
1063
+ // `collections/<query>` and write a path nothing read — the loader moved
1064
+ // and this did not.
1065
+ //
1066
+ // The record's CONTENT still lives in a query's materialization, so the
1067
+ // query that covers this schema is resolved rather than named.
1068
+ const poolDirs = pathType.replace('entities/', '')
1069
+ const { resolveQueriesConfig, poolDirsForSchema } = await import('@uniweb/build/uwx')
1070
+ let queryName = null
1071
+ try {
1072
+ const { declarations } = await resolveQueriesConfig(siteRoot)
1073
+ for (const [name, decl] of Object.entries(declarations || {})) {
1074
+ const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
1075
+ if (dirs && dirs.join('/') === poolDirs) { queryName = name; break }
1076
+ }
1077
+ } catch {
1078
+ /* no resolvable config — fall through to the not-found message below */
1079
+ }
1080
+ const dataPath = queryName
1081
+ ? join(siteRoot, 'public', 'data', `${queryName}.json`)
1082
+ : join(siteRoot, 'public', 'data', '__none__.json')
1069
1083
 
1070
1084
  if (existsSync(dataPath)) {
1071
1085
  const dataRaw = await readFile(dataPath, 'utf-8')
@@ -1607,8 +1621,8 @@ ${colors.bright}Options:${colors.reset}
1607
1621
  --freeform (status/prune) Include free-form translation status
1608
1622
  --json (status) Output as JSON for translation tools
1609
1623
  --by-page (status --missing) Group missing strings by page
1610
- --collections-only (extract/status/audit) Process only collections
1611
- --no-collections (extract/status/audit) Skip collections (pages only)
1624
+ --records-only (extract/status/audit) Process only records
1625
+ --no-records (extract/status/audit) Skip records (pages only)
1612
1626
  --all-stale (update-hash) Update all stale translations at once
1613
1627
 
1614
1628
  ${colors.bright}Configuration:${colors.reset}
@@ -1638,14 +1652,14 @@ ${colors.bright}File Structure:${colors.reset}
1638
1652
  .manifest.json Staleness tracking
1639
1653
  pages/about/hero.md Translated content for /about page, hero section
1640
1654
  page-ids/install/intro.md Translated content by page ID
1641
- collections/articles/getting-started.md
1655
+ entities/article/getting-started.md
1642
1656
 
1643
1657
  ${colors.bright}Examples:${colors.reset}
1644
1658
  ${colors.dim}# Hash-based workflow${colors.reset}
1645
1659
  uniweb i18n extract # Extract all translatable strings
1646
1660
  uniweb i18n extract --dry-run # Preview without writing
1647
1661
  uniweb i18n extract --verbose # Show extracted strings
1648
- uniweb i18n extract --no-collections # Pages only (skip collections)
1662
+ uniweb i18n extract --no-records # Pages only (skip records)
1649
1663
  uniweb i18n generate es fr # Create starter files for Spanish and French
1650
1664
  uniweb i18n generate --empty # Create files with empty values (for translators)
1651
1665
  uniweb i18n generate --force # Overwrite existing locale files
@@ -1658,7 +1672,7 @@ ${colors.bright}Examples:${colors.reset}
1658
1672
  ${colors.dim}# Free-form workflow (complete section replacement)${colors.reset}
1659
1673
  uniweb i18n init-freeform es pages/about hero
1660
1674
  uniweb i18n init-freeform es page-ids/installation intro
1661
- uniweb i18n init-freeform es collections/articles getting-started
1675
+ uniweb i18n init-freeform es entities/article getting-started
1662
1676
  uniweb i18n status --freeform # Show free-form translation status
1663
1677
  uniweb i18n update-hash es --all-stale # Update hashes after review
1664
1678
  uniweb i18n move pages/docs/setup pages/getting-started
@@ -70,6 +70,7 @@ import {
70
70
  readSiteIdentity
71
71
  } from '../utils/site-identity.js'
72
72
  import { isNonInteractive, confirm } from '../utils/interactive.js'
73
+ import { guardEmptyRecords } from '../utils/records-guard.js'
73
74
  import { headProvenance } from '../utils/git.js'
74
75
  import {
75
76
  makeModelResolver,
@@ -93,7 +94,7 @@ import {
93
94
  readPaymentRefusal,
94
95
  reportPaymentRefusal
95
96
  } from '../backend/payment-handoff.js'
96
- import { reportSchemalessCollections } from '../utils/schemaless-report.js'
97
+ import { reportSchemalessQueries } from '../utils/schemaless-report.js'
97
98
  import { uploadSiteData } from '../utils/site-data-upload.js'
98
99
 
99
100
  const c = {
@@ -113,7 +114,20 @@ const say = {
113
114
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
114
115
  }
115
116
 
116
- // Origin-relative serve path → clickable absolute URL (self-serve default).
117
+ // Origin-relative serve path → clickable absolute URL.
118
+ //
119
+ // ⭐ THE TWO SHAPES ARE A CONTRACT, NOT AN INCONSISTENCY — ratified 2026-08-29 and
120
+ // documented in the backend's `wire-layer.md` rather than merely observed. A publish
121
+ // returns an ABSOLUTE url when Cloudflare hosts the site (another origin entirely)
122
+ // and an ORIGIN-RELATIVE path when the backend serves it itself, where its own
123
+ // external origin is not reliably self-reportable from behind an ALB.
124
+ //
125
+ // ⇒ So this branch is implementing the contract, not defending against drift. I
126
+ // reported the two shapes as a violation of "finished values only" in collab
127
+ // framework-backend-812b; the backend checked, found the adjacent ruling that
128
+ // explains the relative arm, and ratified both. Do not "fix" it by demanding one
129
+ // shape — the caller's own origin is the missing half on the relative arm, and we
130
+ // are the caller.
117
131
  function absolutizeServeUrl(origin, url) {
118
132
  if (!url || typeof url !== 'string') return null
119
133
  if (/^https?:\/\//.test(url)) return url
@@ -436,6 +450,14 @@ export async function publish(args = []) {
436
450
  // Non-local @std/registry Model schemas resolve through the backend (same as push).
437
451
  const resolveModel = makeModelResolver({ client, offline: false })
438
452
 
453
+ // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
454
+ // destructive — a placeholder file, created meaning to fill it in — so the count
455
+ // is reported and confirmed before anything is sent.
456
+ {
457
+ const guard = await guardEmptyRecords({ siteDir, args, warn: say.warn, note: say.dim })
458
+ if (!guard.ok) return { exitCode: 1 }
459
+ }
460
+
439
461
  // 3. Partition collections by schema presence (a first emit reads `schemaless`
440
462
  // — collections with no data schema, delivered statically via the ball).
441
463
  let probe
@@ -456,7 +478,7 @@ export async function publish(args = []) {
456
478
  // A product decision the author is usually making unknowingly — say it at warn
457
479
  // level, not dim among everything else. See the helper for what the old
458
480
  // message got wrong.
459
- reportSchemalessCollections(probe.schemaless, say)
481
+ reportSchemalessQueries(probe.schemaless, say)
460
482
  const localAssets = probe.localAssets || []
461
483
 
462
484
  // 3a. A clone with no `$uuid` is bound to no backend site, so every cached map
@@ -579,7 +601,7 @@ export async function publish(args = []) {
579
601
  // `client.discover()` is the mechanism if that changes — `DISCOVERY_DEFAULTS`
580
602
  // makes an absent key non-breaking by construction.
581
603
  if (ball) {
582
- say.info('Uploading collection data…')
604
+ say.info('Uploading schema-less record data…')
583
605
  try {
584
606
  const r = await uploadSiteData({
585
607
  apiBase: client.origin,
@@ -595,9 +617,9 @@ export async function publish(args = []) {
595
617
  for (const f of r.failed) say.dim(` ${f.path} (HTTP ${f.status})`)
596
618
  return { exitCode: 1 }
597
619
  }
598
- say.dim(`Collection data : ${r.uploaded.length} file(s) [${r.mode}]`)
620
+ say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
599
621
  } catch (err) {
600
- say.err(`Collection data upload failed: ${err.message}`)
622
+ say.err(`Record data upload failed: ${err.message}`)
601
623
  return { exitCode: 1 }
602
624
  }
603
625
  }
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * - content lane → `siteContentDocumentToProject` (site.yml/theme.yml/head.html,
12
12
  * pages/**, layout/**), and
13
- * - folder lane → `collectionsToProject` (the folder + record files).
13
+ * - folder lane → `recordsToProject` (the folder + record files).
14
14
  *
15
15
  * Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
16
16
  * misleading. It reconciles the working tree to the backend: section bodies are
@@ -36,7 +36,7 @@
36
36
  *
37
37
  * Usage:
38
38
  * uniweb pull GET both lanes, project to files, prune orphans
39
- * uniweb pull --no-collections Pull pages only; skip the folder (collections) lane
39
+ * uniweb pull --no-records Pull pages only; skip the folder (records) lane
40
40
  * uniweb pull --no-delete Project, but keep files with no backend item
41
41
  * uniweb pull --merge Three-way merge local changes with the backend's
42
42
  * uniweb pull --force Pull over uncommitted local changes (discards them)
@@ -52,7 +52,7 @@
52
52
  * A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
53
53
  * clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
54
54
  * `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
55
- * a tolerant JSON fallback (`extractDocument` / `splitCollectionsPull`). Verified live
55
+ * a tolerant JSON fallback (`extractDocument` / `splitRecordsPull`). Verified live
56
56
  * against the playground backend, 2026-06-17.
57
57
  */
58
58
 
@@ -71,12 +71,11 @@ import yaml from 'js-yaml'
71
71
  import { downloadMissingAssets } from '../backend/asset-download.js'
72
72
  import {
73
73
  siteContentDocumentToProject,
74
- collectionsToProject,
75
- resolveCollectionsConfig,
74
+ recordsToProject,
76
75
  readZip,
77
76
  computeUnitHashes,
78
77
  collectUnitUuids,
79
- collectCollectionUuids
78
+ collectQueryUuids
80
79
  } from '@uniweb/build/uwx'
81
80
  import {
82
81
  readWritten,
@@ -86,7 +85,7 @@ import {
86
85
  import {
87
86
  makeModelResolver,
88
87
  rebankSyncHashes,
89
- writeCollectionUuids,
88
+ writeQueryUuids,
90
89
  mergeBaseVersions,
91
90
  mergeItemBaseVersions,
92
91
  writeUnitBases,
@@ -203,7 +202,7 @@ export function extractDocument(payload) {
203
202
  // Split a collections pull (the folder + the entities it references) into the
204
203
  // folder document and the record documents. Tolerant of an array, an
205
204
  // `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
206
- export function splitCollectionsPull(payload) {
205
+ export function splitRecordsPull(payload) {
207
206
  if (payload?.folder)
208
207
  return { folderDoc: payload.folder, recordDocs: payload.records || [] }
209
208
  const list = Array.isArray(payload)
@@ -240,7 +239,7 @@ export function readPullDocuments(buf) {
240
239
  }
241
240
  return docs
242
241
  }
243
- // JSON fallback — flatten any envelope splitCollectionsPull understands into a
242
+ // JSON fallback — flatten any envelope splitRecordsPull understands into a
244
243
  // flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
245
244
  // `{folder, records}`).
246
245
  let payload
@@ -519,8 +518,8 @@ export async function pull(args = [], deps = {}) {
519
518
  const dryRun = args.includes('--dry-run')
520
519
  const tokenFlag = flagValue(args, '--token')
521
520
  const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
522
- const noCollections =
523
- args.includes('--no-collections') || args.includes('--content-only')
521
+ const noRecords =
522
+ args.includes('--no-records') || args.includes('--content-only')
524
523
  const force = args.includes('--force')
525
524
  const mergeMode = args.includes('--merge')
526
525
 
@@ -598,7 +597,7 @@ export async function pull(args = [], deps = {}) {
598
597
  info(
599
598
  `Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`
600
599
  )
601
- if (!noCollections) info(`Dry run — would also pull collections`)
600
+ if (!noRecords) info(`Dry run — would also pull records`)
602
601
  return { exitCode: 0 }
603
602
  }
604
603
 
@@ -691,8 +690,8 @@ export async function pull(args = [], deps = {}) {
691
690
  writeItemUuids(siteDir, collectUnitUuids(siteDoc))
692
691
  // The collections section's identity has no file to live in either — same
693
692
  // reason, same remedy, keyed by name. A pull is the other route by which a
694
- // copy can recover it (see readCollectionUuids).
695
- writeCollectionUuids(siteDir, collectCollectionUuids(siteDoc))
693
+ // copy can recover it (see readQueryUuids).
694
+ writeQueryUuids(siteDir, collectQueryUuids(siteDoc))
696
695
  // Bring the media down BEFORE projecting: a newly-landed asset gains a map
697
696
  // entry, and the projection reads that map to put authored paths back. Run
698
697
  // after, and this pull's new assets would project as URLs and only restore
@@ -777,13 +776,13 @@ export async function pull(args = [], deps = {}) {
777
776
  // Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
778
777
  // (the backend resolves the site's `@uniweb/folder` from it; the framework never
779
778
  // holds a folder uuid). Models are resolved by name (async) up front, so
780
- // collectionsToProject keeps its synchronous contract. A 304 leaves files as-is.
781
- if (!noCollections) {
782
- const folder = await getDocs('collections', () =>
779
+ // recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
780
+ if (!noRecords) {
781
+ const folder = await getDocs('records', () =>
783
782
  client.pullFolder(siteContentUuid, { etag: etagFolder })
784
783
  )
785
784
  if (folder && !folder.notModified && folder.docs?.length) {
786
- const { folderDoc, recordDocs } = splitCollectionsPull(folder.docs)
785
+ const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
787
786
  const resolveModel = makeModelResolver({ client })
788
787
  const declByModel = new Map()
789
788
  for (const model of [
@@ -795,18 +794,20 @@ export async function pull(args = [], deps = {}) {
795
794
  note(`! could not resolve model ${model}: ${err.message}`)
796
795
  }
797
796
  }
798
- const collectionsConfig = await resolveCollectionsConfig(siteDir).catch(
799
- () => null
800
- )
801
- const report = collectionsToProject({
797
+ // NO QUERY CONFIG. A record's home is decided by what it IS — its
798
+ // `$model` names the pool folder — not by any query that happens to select
799
+ // it. `recordsToProject` reads `site.yml::$org` itself, so a `@/x`
800
+ // model the producer resolved to `@org/x` is placed back where the author
801
+ // wrote it.
802
+ const report = recordsToProject({
802
803
  folderDoc,
803
804
  recordDocs,
804
805
  siteRoot: siteDir,
805
806
  opts: {
806
- resolveDeclaration: (name) => declByModel.get(name) || null,
807
- collectionsConfig
807
+ resolveDeclaration: (name) => declByModel.get(name) || null
808
808
  }
809
809
  })
810
+ if (report.records === 'updated') info('Wrote records.yml')
810
811
  records += report.placed.length + report.updated.length
811
812
  for (const s of report.skipped)
812
813
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -840,7 +841,7 @@ export async function pull(args = [], deps = {}) {
840
841
  siteDir,
841
842
  [
842
843
  ...wrote,
843
- ...['site.yml', 'theme.yml', 'head.html', 'collections.yml'].map((f) =>
844
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
844
845
  join(siteDir, f)
845
846
  )
846
847
  ],
@@ -69,7 +69,7 @@ import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
69
69
  import { BackendClient } from '../backend/client.js'
70
70
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
71
71
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
72
- import { reportSchemalessCollections } from '../utils/schemaless-report.js'
72
+ import { reportSchemalessQueries } from '../utils/schemaless-report.js'
73
73
  import { readOrgFlag } from '../utils/args.js'
74
74
  import { checkFlags } from '../utils/flag-guard.js'
75
75
  import {
@@ -77,6 +77,7 @@ import {
77
77
  readSiteIdentity
78
78
  } from '../utils/site-identity.js'
79
79
  import { confirm } from '../utils/interactive.js'
80
+ import { guardEmptyRecords } from '../utils/records-guard.js'
80
81
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
81
82
  import {
82
83
  makeModelResolver,
@@ -85,7 +86,7 @@ import {
85
86
  readItemBaseVersions,
86
87
  readItemUuids,
87
88
  readFolderItemUuids,
88
- readCollectionUuids,
89
+ readQueryUuids,
89
90
  ensureItemUuids,
90
91
  ensureSiteExists,
91
92
  clearRemoteSyncStateIfUnbound,
@@ -369,6 +370,15 @@ export async function push(args = [], deps = {}) {
369
370
  // It also has to be this emit that carries `assetRewrite` below: the push cache
370
371
  // stores hashes of the REWRITTEN content, so the emit compared against it must
371
372
  // rewrite too, or every entity reads as changed forever.
373
+ // ⛔ AN EMPTY `records.yml` REMOVES. It is the one path where an ordinary act is
374
+ // destructive — a placeholder file, created meaning to fill it in — so the count
375
+ // is reported and confirmed before anything is sent. The format stays honest;
376
+ // the asking happens here.
377
+ if (!dryRun) {
378
+ const guard = await guardEmptyRecords({ siteDir, args, warn, note })
379
+ if (!guard.ok) return { exitCode: 1 }
380
+ }
381
+
372
382
  let assetRewrite = null
373
383
  let assetIds = null
374
384
  if (!output && !dryRun) {
@@ -475,9 +485,9 @@ export async function push(args = [], deps = {}) {
475
485
  pkg = await emitSyncPackages(siteDir, {
476
486
  // Placement identity for the folder — see writeFolderItemUuids.
477
487
  folderItemUuids: readFolderItemUuids(siteDir),
478
- // Identity for the `collections` section — see readCollectionUuids. Keyed by
488
+ // Identity for the `queries` section — see readQueryUuids. Keyed by
479
489
  // name, because a declaration has no file for a path-keyed map to hold.
480
- collectionUuids: readCollectionUuids(siteDir),
490
+ queryUuids: readQueryUuids(siteDir),
481
491
  // Resolves a foundation-relative `@/x` model ref into `@org/x`.
482
492
  ...(asOrg ? { org: asOrg } : {}),
483
493
  ...(foundationDir ? { foundationDir } : {}),
@@ -509,14 +519,14 @@ export async function push(args = [], deps = {}) {
509
519
  error(`Could not build the sync package: ${err.message}`)
510
520
  return { exitCode: 2 }
511
521
  }
512
- const { siteContent, collections, siteContentUuid, warnings, skipped } = pkg
522
+ const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
513
523
  log('')
514
524
  for (const w of warnings) note(`! ${w}`)
515
525
  // Warn level, not dim: this is the author choosing entities vs static files.
516
- reportSchemalessCollections(pkg.schemaless, { warn, dim: note })
526
+ reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
517
527
 
518
528
  const totalEntities =
519
- (siteContent?.entityCount || 0) + (collections?.entityCount || 0)
529
+ (siteContent?.entityCount || 0) + (records?.entityCount || 0)
520
530
 
521
531
  // Nothing changed since the last push — the backend is already up to date.
522
532
  if (totalEntities === 0) {
@@ -529,10 +539,10 @@ export async function push(args = [], deps = {}) {
529
539
  info(
530
540
  `${colors.bright}site-content${colors.reset} → ${siteContent.models.join(', ')}`
531
541
  )
532
- if (collections) {
533
- const n = collections.entityCount
542
+ if (records) {
543
+ const n = records.entityCount
534
544
  info(
535
- `${colors.bright}collections${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${collections.models.join(', ')}`
545
+ `${colors.bright}records${colors.reset} (${n} entit${n === 1 ? 'y' : 'ies'}) → ${records.models.join(', ')}`
536
546
  )
537
547
  }
538
548
  if (skipped) note(`${skipped} unchanged, skipped`)
@@ -542,11 +552,11 @@ export async function push(args = [], deps = {}) {
542
552
  const base = output.replace(/\.uwx$/, '')
543
553
  if (siteContent)
544
554
  writeFileSync(resolve(`${base}.site-content.uwx`), siteContent.buffer)
545
- if (collections)
546
- writeFileSync(resolve(`${base}.collections.uwx`), collections.buffer)
555
+ if (records)
556
+ writeFileSync(resolve(`${base}.records.uwx`), records.buffer)
547
557
  const lanes = [
548
558
  siteContent && 'site-content',
549
- collections && 'collections'
559
+ records && 'records'
550
560
  ].filter(Boolean)
551
561
  success(`Wrote ${lanes.join(' + ')} .uwx — not submitted`)
552
562
  return { exitCode: 0 }
@@ -558,7 +568,7 @@ export async function push(args = [], deps = {}) {
558
568
  `Dry run — would ${verb} content at ${colors.dim}${client.origin}${colors.reset}`
559
569
  )
560
570
  }
561
- if (collections) {
571
+ if (records) {
562
572
  info(
563
573
  `Dry run — would push the folder at ${colors.dim}${client.origin}${colors.reset}`
564
574
  )
@@ -269,7 +269,7 @@ export async function validate(args = []) {
269
269
  return { exitCode: 0 }
270
270
  }
271
271
 
272
- // The data pipeline (collectSiteContent / processCollections) prints progress
272
+ // The data pipeline (collectSiteContent / processQueries) prints progress
273
273
  // via console.log. Route that to stderr while the engine runs so stdout stays
274
274
  // clean — pure JSON for `--json`, just the report otherwise. `log` captured
275
275
  // the original stdout writer at module load, so our own output is unaffected.
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-28T23:49:23.885Z",
3
+ "generatedAt": "2026-08-29T19:59:22.061Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.29.1",
6
+ "version": "0.30.0",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -27,7 +27,7 @@
27
27
  "deps": []
28
28
  },
29
29
  "@uniweb/core": {
30
- "version": "0.13.1",
30
+ "version": "0.14.0",
31
31
  "path": "framework/core",
32
32
  "deps": [
33
33
  "@uniweb/semantic-parser",
@@ -40,14 +40,14 @@
40
40
  "deps": []
41
41
  },
42
42
  "@uniweb/icons": {
43
- "version": "0.4.4",
43
+ "version": "0.4.5",
44
44
  "path": "framework/icons",
45
45
  "deps": [
46
46
  "@uniweb/core"
47
47
  ]
48
48
  },
49
49
  "@uniweb/kit": {
50
- "version": "0.14.0",
50
+ "version": "0.15.0",
51
51
  "path": "framework/kit",
52
52
  "deps": [
53
53
  "@uniweb/core",
@@ -66,7 +66,7 @@
66
66
  "deps": []
67
67
  },
68
68
  "@uniweb/projections": {
69
- "version": "0.5.1",
69
+ "version": "0.5.2",
70
70
  "path": "framework/projections",
71
71
  "deps": [
72
72
  "@uniweb/content-writer",
@@ -74,7 +74,7 @@
74
74
  ]
75
75
  },
76
76
  "@uniweb/runtime": {
77
- "version": "0.13.1",
77
+ "version": "0.13.2",
78
78
  "path": "framework/runtime",
79
79
  "deps": [
80
80
  "@uniweb/core",
@@ -77,12 +77,12 @@ const VERBS = {
77
77
  pull: [
78
78
  '--backend', '--content-only', '--dry-run', '--force', '--merge',
79
79
  '--no-assets',
80
- '--no-collections', '--no-delete', '--no-prune', '--registry', '--token',
80
+ '--no-records', '--no-delete', '--no-prune', '--registry', '--token',
81
81
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
82
82
  '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
83
83
  ],
84
84
  clone: [
85
- '--backend', '--content-only', '--no-assets', '--no-collections', '--path',
85
+ '--backend', '--content-only', '--no-assets', '--no-records', '--path',
86
86
  '--project', '--registry', '--token', '--org', '--as-org'
87
87
  ],
88
88
  register: [
package/src/utils/git.js CHANGED
@@ -32,11 +32,17 @@ import yaml from 'js-yaml'
32
32
  * rather than assuming the defaults.
33
33
  */
34
34
  export function siteContentRoots(siteDir) {
35
+ // ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
36
+ // Its predecessor lived at `collections/collections.yml`, so the `collections`
37
+ // root added below already covered it and the bare `'collections.yml'` entry
38
+ // that used to sit in this list resolved to a path no site ever had. A
39
+ // root-level file has no directory entry standing in for it.
35
40
  const roots = new Set([
36
41
  'site.yml',
37
42
  'theme.yml',
38
43
  'head.html',
39
- 'collections.yml',
44
+ 'queries.yml',
45
+ 'records.yml',
40
46
  'locales'
41
47
  ])
42
48
  let paths = {}
@@ -48,7 +54,7 @@ export function siteContentRoots(siteDir) {
48
54
  }
49
55
  roots.add(paths.pages || 'pages')
50
56
  roots.add(paths.layout || 'layout')
51
- roots.add(paths.collections || 'collections')
57
+ roots.add(paths.entities || 'entities')
52
58
  return [...roots]
53
59
  }
54
60