uniweb 0.83.0 → 0.85.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.
@@ -55,10 +55,11 @@ import {
55
55
  } from '@uniweb/build/site'
56
56
  import { emitSyncPackages } from '@uniweb/build/uwx'
57
57
  import {
58
- decideDeclaration,
59
- fingerprintRequest,
58
+ bankLanguages,
60
59
  reconcile,
61
- reconcileRequest
60
+ announceServices,
61
+ foundationSupports,
62
+ recordsNotAsked
62
63
  } from '../backend/service-request.js'
63
64
  import { isSiteRelativeExtensionUrl } from '@uniweb/build'
64
65
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
@@ -235,19 +236,6 @@ export function unservedLanguages(asked, served) {
235
236
  return asked.filter((l) => !got.has(l))
236
237
  }
237
238
 
238
- function describeServices(rows) {
239
- if (!Array.isArray(rows) || rows.length === 0) return 'nothing'
240
- return rows
241
- .map((r) => {
242
- const name = typeof r?.name === 'string' ? r.name : '?'
243
- // A row that omits `enabled` is an ask, not a refusal — the backend's three
244
- // states. Only an explicit `false` reads as off.
245
- return r?.enabled === false ? `${name} (off)` : name
246
- })
247
- .sort()
248
- .join(', ')
249
- }
250
-
251
239
  async function persistLastDeploy(siteDir, opts) {
252
240
  if (opts.saveDeploys === false) return
253
241
  try {
@@ -392,9 +380,9 @@ export async function publish(args = []) {
392
380
  resolved = resolvePublishTarget(deployYml, client.origin, {
393
381
  defaultBackend: DEFAULT_BACKEND_ORIGIN
394
382
  })
395
- // The last request we are known to have sent TO THIS BACKEND, for the declaration
396
- // gate below. Read from the SAME deploy.yml load — one read, and the memo is the
397
- // only durable record of it (see backend/service-request.js for why not the cache).
383
+ // The language selection last sent TO THIS BACKEND, for its reconcile below. Read
384
+ // from the SAME deploy.yml load — one read, and the memo is the only durable record
385
+ // of it (see backend/service-request.js).
398
386
  priorRequest = deployYml?.deploys?.[resolved.targetName] || null
399
387
  } catch {
400
388
  // Malformed/ambiguous deploy.yml — don't block the publish on the memo.
@@ -818,98 +806,20 @@ export async function publish(args = []) {
818
806
  const injectInfo = {
819
807
  ...(fnd.ref ? { foundation: fnd.ref } : {})
820
808
  }
821
- // ⛔ IS THE FILE ASKING FOR ANYTHING BY ITS `$services` / `$secrets` BLOCK?
822
- //
823
- // The blocks ride inside the site-content document, so without this gate every
824
- // push re-sends them — and the backend REPLACES what it is sent. A paragraph
825
- // edit would therefore overwrite whatever the stored request has become, which
826
- // in the consent workflow is a decision the owner made in the app. Under "the
827
- // file is a request", an unchanged block is not asking for anything.
828
- //
829
- // ⚠️ The residual window, stated because it is real and narrow: the base is
830
- // banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
831
- // the next publish is not seen — the pulled block reads as unchanged-from-nothing
832
- // and is declared. It closes when the status route carries the stored request
833
- // (backend is adding it) and we compare against theirs instead of our memory.
834
- //
835
- // ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
836
- // banked fingerprint says what WE last sent; the status read says what the site
837
- // actually has. Only the second one sees a change made in the app, which is where
838
- // the consent workflow's decisions happen — so this is what closes the window
839
- // between a `uniweb pull` and the next publish.
840
- //
841
- // ⚖️ Degrades to the banked comparison on any failure — an older backend, a
842
- // network blip, a site never pushed. That is the shipped behaviour and it is safe:
843
- // it withholds an unchanged block and sends a changed one; it merely cannot see
844
- // the app's side.
845
- // What this site is PROVISIONED with on the backend being published to.
846
- const provisioned = readBackendState(siteDir, client.origin)
847
- const boundUuid = provisioned.site?.uuid || null
848
- let declaration = decideDeclaration(siteYml, priorRequest, provisioned)
849
- let adopted = null
850
- // Before the push, so a never-synced site has no uuid and simply skips this.
851
- const status =
852
- // This backend's site, from sync.json. It read `site.yml::$uuid`, so after step 4
853
- // the remote reconcile below never ran and every publish declared blind.
854
- boundUuid
855
- ? await client.siteStatus(boundUuid)
856
- : null
857
- if (status && Array.isArray(status.services)) {
858
- const r = reconcileRequest(siteYml, status.services, priorRequest, provisioned)
859
- if (r.action === 'none') {
860
- declaration = { declare: false, reason: 'in-sync' }
861
- } else if (r.action === 'send') {
862
- declaration = { declare: true, reason: 'changed' }
863
- } else if (r.action === 'adopt') {
864
- // The owner decided in the app and this file is simply behind. Nothing to
865
- // ask for, so nothing is sent — and the file can be brought in line, which
866
- // is offered rather than done, because site.yml is theirs.
867
- declaration = { declare: false, reason: 'adopt' }
868
- adopted = status.services
869
- } else {
870
- // ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
871
- // publish is a separate thing the owner asked for, and blocking it over a
872
- // services disagreement couples two unrelated intents. Not a guess either;
873
- // the request stays in their file, unsent, and they are told.
874
- declaration = { declare: false, reason: 'conflict' }
875
- adopted = status.services
876
- }
877
- }
809
+ // ⭐ WHAT THE SITE HAS — read before the push, for the language selection. Only this
810
+ // read sees a decision the owner made in the app since this clone last synced. It
811
+ // reads this backend's site, from sync.json; a never-synced site has none.
812
+ const boundUuid = readBackendState(siteDir, client.origin).site?.uuid || null
813
+ const status = boundUuid ? await client.siteStatus(boundUuid) : null
878
814
 
879
- // ⛔ EVERY STRING BELOW IS FOR A SITE OWNER, NOT FOR US.
880
- //
881
- // "request", "declaration", "send", "adopt", "reconcile" are how this file
882
- // MODELS the problem and they are the wrong words to say out loud: an author
883
- // does not think they are sending a request, they think they want their site to
884
- // have search. Say services, on and off, site.yml and your site. The internal
885
- // vocabulary stays in the code and the comments, where it earns its precision.
886
- //
887
- // ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
888
- //
889
- // `conflict` means the file and the site both moved since we last agreed, so
890
- // neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
891
- // is advice that CANNOT WORK: with no banked base the file has nothing to move
892
- // relative to, so editing it produces the same conflict forever. That shipped
893
- // for one commit. Asking is the only thing that resolves it.
894
- if (declaration.reason === 'conflict') {
895
- say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
896
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
897
- say.dim(` on your site: ${describeServices(adopted)}`)
898
- if (isNonInteractive(args)) {
899
- say.dim(' Left your site as it is — run without --non-interactive to choose.')
900
- } else if (await confirm('Use the services listed in site.yml?', false)) {
901
- declaration = { declare: true, reason: 'resolved-send' }
902
- adopted = null
903
- } else {
904
- // Declining to send is not yet a decision to take theirs, so this falls
905
- // through to the offer below and "neither, leave it alone" stays available.
906
- declaration = { declare: false, reason: 'adopt' }
907
- }
908
- }
815
+ // ⭐ THE SERVICES are stated by the push itself — the file's, and off for each held
816
+ // one it no longer lists (`statedServices`) — and the backend decides per service.
817
+ // Said here: what the file asks that will not be sent as written.
818
+ announceServices({ siteYml, say, supports: await foundationSupports(siteDir, siteYml) })
909
819
 
910
- // ⭐ THE SAME QUESTION FOR THE LANGUAGE SELECTION, and it is the one that costs.
820
+ // ⭐ THE LANGUAGE SELECTION IS A REQUEST TOO, and it is the one that costs.
911
821
  //
912
- // `publishLanguages` is a request like `$services`: pushed up, projected back on
822
+ // `publishLanguages` is a request like `services`: pushed up, projected back on
913
823
  // pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
914
824
  // today — which is why this was nearly skipped — but a base is not only for
915
825
  // detecting an overwrite. Without one, a selection that has always been in the
@@ -938,34 +848,6 @@ export async function publish(args = []) {
938
848
  }
939
849
  }
940
850
 
941
- if (declaration.reason === 'adopt' && adopted) {
942
- // ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
943
- // decision made in the app — but the same state follows a request of ours the
944
- // site refused, where nothing of theirs changed and ours simply did not take.
945
- // We cannot tell those apart here, so the wording claims neither.
946
- say.info('Your site has different services than site.yml lists.')
947
- say.dim(` in sync.json: ${describeServices(provisioned.services)}`)
948
- say.dim(` on your site: ${describeServices(adopted)}`)
949
- // ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
950
- // silently rewrites an authored file is the surprise this seam exists to
951
- // avoid. Default No, and declining costs nothing: the site is already
952
- // correct, only the file is behind, and the offer returns next publish.
953
- //
954
- // ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
955
- // been asked which they meant and said "not mine", taking the site's is the
956
- // other half of the same question, not a silent overwrite of an edit.
957
- if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
958
- const { writeSiteConfig } = await import('@uniweb/build/uwx')
959
- writeSiteConfig(siteDir, { $services: adopted })
960
- // Keep the in-memory copy in step, or the deploy.yml bank below records the
961
- // file as it WAS and the offer repeats forever.
962
- siteYml.$services = adopted
963
- say.ok('site.yml updated.')
964
- }
965
- } else if (!declaration.declare && declaration.reason !== 'adopt') {
966
- say.dim('Services unchanged.')
967
- }
968
-
969
851
  // publish rides the same gated push as `uniweb push`: if an app author has
970
852
  // edited since this clone last synced, the push is refused rather than
971
853
  // overwriting them, and nothing goes live. `--force` drops the precondition.
@@ -983,7 +865,6 @@ export async function publish(args = []) {
983
865
  backend: client.origin,
984
866
  // The keys this deployment's Sections take — see deploymentFields.
985
867
  ...fields,
986
- ...(declaration.declare ? {} : { declareServices: false }),
987
868
  // Placement identity for the folder — see writeFolderItemUuids.
988
869
  folderItemUuids: readFolderItemUuids(siteDir, client.origin),
989
870
  // Identity for the records' list items — see readRecordItemUuids.
@@ -1017,6 +898,10 @@ export async function publish(args = []) {
1017
898
  if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
1018
899
  return { exitCode: 1 }
1019
900
  }
901
+ // ⭐ `records` is what delivers the records the pages show on the published site — said
902
+ // here, at publish, and never at push: syncing records does not depend on it.
903
+ const notAsked = recordsNotAsked({ siteYml, shown: pkg.recordsShown })
904
+ if (notAsked) say.warn(notAsked)
1020
905
  const report = {
1021
906
  info: (m) => say.info(m),
1022
907
  note: (m) => say.dim(m),
@@ -1127,24 +1012,9 @@ export async function publish(args = []) {
1127
1012
  lastDeploy: {
1128
1013
  at: new Date().toISOString(),
1129
1014
  host: 'uniweb',
1130
- // The request this publish is known to have sent — the base the declaration
1131
- // gate compares against next time. ⛔ A FINGERPRINT, never the block:
1132
- // deploy.yml is committed, `$secrets` carries secret material and a
1133
- // service's `config` is opaque, so recording either verbatim would write
1134
- // them into git. Absent when the file declares no block.
1135
- ...(declaration.declare
1136
- ? fingerprintRequest(siteYml, provisioned)
1137
- : {
1138
- // Nothing was sent, so the base is unchanged — carry it forward
1139
- // rather than dropping it, or the next publish would read "no record"
1140
- // and declare.
1141
- ...(priorRequest?.servicesRequest
1142
- ? { servicesRequest: priorRequest.servicesRequest }
1143
- : {}),
1144
- ...(priorRequest?.secretsRequest
1145
- ? { secretsRequest: priorRequest.secretsRequest }
1146
- : {})
1147
- }),
1015
+ // The language selection this publish sent — the base its reconcile compares
1016
+ // against next time. ⛔ A FINGERPRINT: the selection itself is in site.yml.
1017
+ ...bankLanguages(siteYml),
1148
1018
  // What was actually shipped. A version number can't answer that — two
1149
1019
  // publishes of "0.1.0" are not the same content — and after the fact the
1150
1020
  // working tree has moved on. `dirty` matters as much as the sha: it says the
@@ -95,7 +95,8 @@ import {
95
95
  rebankSyncHashes,
96
96
  writeQueryUuids,
97
97
  mergeBaseVersions,
98
- mergeItemBaseVersions,
98
+ writeItemBaseVersions,
99
+ pulledItemVersions,
99
100
  writeUnitBases,
100
101
  writeItemUuids,
101
102
  writeFolderItemUuids
@@ -887,6 +888,9 @@ export async function pull(args = [], deps = {}) {
887
888
  // The Models the pulled site's queries name, as the push qualified them — the re-bank
888
889
  // below resolves with them too (see there).
889
890
  let pulledQueryModels = []
891
+ // The versions this copy holds after the pull: every site-content item the pulled
892
+ // document carries (`pulledItemVersions`), banked below once the files have taken it.
893
+ let pulledVersions = null
890
894
  if (content && !content.notModified) {
891
895
  const siteDoc =
892
896
  content.docs &&
@@ -905,6 +909,7 @@ export async function pull(args = [], deps = {}) {
905
909
  // The next push re-establishes it; until then our side reports as unknown,
906
910
  // which is honest rather than wrong.
907
911
  writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
912
+ pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
908
913
  // Per-item identity for the next push. Without it the backend reads our
909
914
  // records as new and re-mints every page and section row.
910
915
  writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
@@ -1216,7 +1221,13 @@ export async function pull(args = [], deps = {}) {
1216
1221
  // Records this pull did not place were not taken, so neither is their lane.
1217
1222
  if (lane === folderLane && recordsNotPlaced) continue
1218
1223
  mergeBaseVersions(siteDir, client.origin, lane.versions)
1219
- mergeItemBaseVersions(siteDir, client.origin, lane.itemVersions)
1224
+ }
1225
+ // ⭐ The site-content items' versions REPLACE what this copy held: a pull is how it
1226
+ // sees the site, and a version kept for an item the site no longer has would be sent
1227
+ // again, asking the backend to delete it. The folder lane's are not kept — that lane
1228
+ // is gated by its entity version. (Both were merged into one map until 2026-10-07.)
1229
+ if (pulledVersions && content && !content.notModified && !content.refused) {
1230
+ writeItemBaseVersions(siteDir, client.origin, pulledVersions)
1220
1231
  }
1221
1232
  // Persist the ETags so the next pull is conditional (304 when unchanged). The
1222
1233
  // folder's is DROPPED when records were not placed, so the next pull fetches
@@ -74,7 +74,8 @@ import {
74
74
  syncedElsewhere,
75
75
  describeSyncedElsewhere
76
76
  } from '../utils/site-identity.js'
77
- import { confirm } from '../utils/interactive.js'
77
+ import { confirm, isNonInteractive } from '../utils/interactive.js'
78
+ import { announceServices, foundationSupports } from '../backend/service-request.js'
78
79
  import { guardEmptyRecords } from '../utils/records-guard.js'
79
80
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
80
81
  import {
@@ -520,6 +521,10 @@ export async function push(args = [], deps = {}) {
520
521
  ref: siteYml?.foundation
521
522
  })
522
523
  }
524
+ // ⭐ THE SERVICES `site.yml` ASKS FOR are stated by the emit — the file's, and off for
525
+ // each held one it no longer lists (`statedServices`) — and the backend decides per
526
+ // service. Said here: what the file asks that will not be sent as written.
527
+ announceServices({ siteYml, say: { warn }, supports: await foundationSupports(siteDir, siteYml) })
523
528
  const emitOptions = {
524
529
  backend: client.origin,
525
530
  // Placement identity for the folder — see writeFolderItemUuids.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-06T02:16:00.292Z",
3
+ "generatedAt": "2026-10-07T11:39:54.164Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.6.10",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.76.1",
13
+ "version": "0.78.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.37.1",
38
+ "version": "0.37.2",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.17",
58
+ "version": "0.19.18",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -84,7 +84,7 @@
84
84
  ]
85
85
  },
86
86
  "@uniweb/runtime": {
87
- "version": "0.29.1",
87
+ "version": "0.29.2",
88
88
  "path": "framework/runtime",
89
89
  "deps": [
90
90
  "@uniweb/core",
@@ -117,7 +117,7 @@
117
117
  "deps": []
118
118
  },
119
119
  "@uniweb/templates": {
120
- "version": "0.17.14",
120
+ "version": "0.18.0",
121
121
  "path": "framework/templates",
122
122
  "deps": []
123
123
  },
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.20",
130
+ "version": "0.10.22",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",