mapples 0.2.0-beta.1 → 0.2.0-beta.12

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.
Files changed (171) hide show
  1. package/README.md +133 -22
  2. package/dist/adopt/scan.d.ts +8 -2
  3. package/dist/adopt/scan.js +31 -13
  4. package/dist/adopt/scan.js.map +1 -1
  5. package/dist/api/agent.d.ts +85 -0
  6. package/dist/api/agent.js +92 -0
  7. package/dist/api/agent.js.map +1 -0
  8. package/dist/api/client.d.ts +12 -1
  9. package/dist/api/client.js +23 -2
  10. package/dist/api/client.js.map +1 -1
  11. package/dist/api/commit.d.ts +9 -0
  12. package/dist/api/commit.js +51 -2
  13. package/dist/api/commit.js.map +1 -1
  14. package/dist/api/errors.d.ts +1 -1
  15. package/dist/api/errors.js +15 -2
  16. package/dist/api/errors.js.map +1 -1
  17. package/dist/api/objects.d.ts +39 -6
  18. package/dist/api/objects.js +204 -11
  19. package/dist/api/objects.js.map +1 -1
  20. package/dist/api/poll.d.ts +5 -3
  21. package/dist/api/poll.js +17 -6
  22. package/dist/api/poll.js.map +1 -1
  23. package/dist/api/txPlan.d.ts +15 -0
  24. package/dist/api/txPlan.js +196 -0
  25. package/dist/api/txPlan.js.map +1 -0
  26. package/dist/assets/assets.d.ts +21 -0
  27. package/dist/assets/assets.js +62 -4
  28. package/dist/assets/assets.js.map +1 -1
  29. package/dist/codegen/emit.d.ts +4 -7
  30. package/dist/codegen/emit.js +10 -29
  31. package/dist/codegen/emit.js.map +1 -1
  32. package/dist/codegen/imports.d.ts +8 -15
  33. package/dist/codegen/imports.js +27 -72
  34. package/dist/codegen/imports.js.map +1 -1
  35. package/dist/codegen/instances.d.ts +5 -0
  36. package/dist/codegen/instances.js +30 -0
  37. package/dist/codegen/instances.js.map +1 -0
  38. package/dist/codegen/styles.d.ts +1 -0
  39. package/dist/codegen/styles.js +3 -0
  40. package/dist/codegen/styles.js.map +1 -0
  41. package/dist/commands/agent.d.ts +2 -0
  42. package/dist/commands/agent.js +87 -0
  43. package/dist/commands/agent.js.map +1 -0
  44. package/dist/commands/create.js +1 -0
  45. package/dist/commands/create.js.map +1 -1
  46. package/dist/commands/impl/adoptImpl.js +3 -0
  47. package/dist/commands/impl/adoptImpl.js.map +1 -1
  48. package/dist/commands/impl/agentImpl.d.ts +23 -0
  49. package/dist/commands/impl/agentImpl.js +426 -0
  50. package/dist/commands/impl/agentImpl.js.map +1 -0
  51. package/dist/commands/impl/createImpl.js +31 -4
  52. package/dist/commands/impl/createImpl.js.map +1 -1
  53. package/dist/commands/impl/initImpl.js +35 -5
  54. package/dist/commands/impl/initImpl.js.map +1 -1
  55. package/dist/commands/impl/syncImpl.js +30 -5
  56. package/dist/commands/impl/syncImpl.js.map +1 -1
  57. package/dist/commands/init.js +1 -0
  58. package/dist/commands/init.js.map +1 -1
  59. package/dist/index.js +12 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/merge/applyOps.d.ts +5 -2
  62. package/dist/merge/applyOps.js +75 -32
  63. package/dist/merge/applyOps.js.map +1 -1
  64. package/dist/merge/childrenOrder.d.ts +13 -0
  65. package/dist/merge/childrenOrder.js +51 -0
  66. package/dist/merge/childrenOrder.js.map +1 -0
  67. package/dist/merge/diff.d.ts +20 -1
  68. package/dist/merge/diff.js +77 -17
  69. package/dist/merge/diff.js.map +1 -1
  70. package/dist/merge/threeWay.d.ts +2 -2
  71. package/dist/merge/threeWay.js +74 -10
  72. package/dist/merge/threeWay.js.map +1 -1
  73. package/dist/merge/toEdits.d.ts +1 -3
  74. package/dist/merge/toEdits.js +3 -91
  75. package/dist/merge/toEdits.js.map +1 -1
  76. package/dist/nav/navigatorTree.d.ts +78 -0
  77. package/dist/nav/navigatorTree.js +419 -0
  78. package/dist/nav/navigatorTree.js.map +1 -0
  79. package/dist/nav/placement.d.ts +53 -0
  80. package/dist/nav/placement.js +260 -0
  81. package/dist/nav/placement.js.map +1 -0
  82. package/dist/nav/routeTree.d.ts +8 -2
  83. package/dist/nav/routeTree.js +85 -152
  84. package/dist/nav/routeTree.js.map +1 -1
  85. package/dist/nav/screenOptions.d.ts +15 -0
  86. package/dist/nav/screenOptions.js +213 -0
  87. package/dist/nav/screenOptions.js.map +1 -0
  88. package/dist/project/agentState.d.ts +30 -0
  89. package/dist/project/agentState.js +42 -0
  90. package/dist/project/agentState.js.map +1 -0
  91. package/dist/project/baseStore.d.ts +19 -1
  92. package/dist/project/baseStore.js +28 -0
  93. package/dist/project/baseStore.js.map +1 -1
  94. package/dist/project/componentsMap.d.ts +7 -0
  95. package/dist/project/componentsMap.js +21 -0
  96. package/dist/project/componentsMap.js.map +1 -0
  97. package/dist/project/config.d.ts +12 -3
  98. package/dist/project/config.js +11 -2
  99. package/dist/project/config.js.map +1 -1
  100. package/dist/project/env.d.ts +10 -3
  101. package/dist/project/env.js +23 -5
  102. package/dist/project/env.js.map +1 -1
  103. package/dist/project/pagesMap.d.ts +2 -0
  104. package/dist/project/pagesMap.js.map +1 -1
  105. package/dist/project/secret.d.ts +6 -2
  106. package/dist/project/secret.js +11 -5
  107. package/dist/project/secret.js.map +1 -1
  108. package/dist/services/adoptService.d.ts +3 -0
  109. package/dist/services/adoptService.js +357 -38
  110. package/dist/services/adoptService.js.map +1 -1
  111. package/dist/services/agentInstall.d.ts +80 -0
  112. package/dist/services/agentInstall.js +309 -0
  113. package/dist/services/agentInstall.js.map +1 -0
  114. package/dist/services/agentLease.d.ts +2 -0
  115. package/dist/services/agentLease.js +32 -0
  116. package/dist/services/agentLease.js.map +1 -0
  117. package/dist/services/clientFactory.d.ts +2 -0
  118. package/dist/services/clientFactory.js +20 -3
  119. package/dist/services/clientFactory.js.map +1 -1
  120. package/dist/services/doctorService.js +77 -66
  121. package/dist/services/doctorService.js.map +1 -1
  122. package/dist/services/scaffold.d.ts +3 -0
  123. package/dist/services/scaffold.js +16 -0
  124. package/dist/services/scaffold.js.map +1 -1
  125. package/dist/services/syncEngine.d.ts +7 -0
  126. package/dist/services/syncEngine.js +766 -306
  127. package/dist/services/syncEngine.js.map +1 -1
  128. package/dist/style/theme.js +17 -1
  129. package/dist/style/theme.js.map +1 -1
  130. package/dist/types.d.ts +55 -2
  131. package/dist/types.js +25 -0
  132. package/dist/types.js.map +1 -1
  133. package/dist/ui/report.d.ts +15 -0
  134. package/dist/ui/report.js +30 -0
  135. package/dist/ui/report.js.map +1 -1
  136. package/package.json +4 -2
  137. package/templates/agent/agents-md/AGENTS.md +8 -0
  138. package/templates/agent/claude/CLAUDE.md +7 -0
  139. package/templates/agent/claude/mcp.json +11 -0
  140. package/templates/agent/claude/settings.json +26 -0
  141. package/templates/agent/codex/config.toml +6 -0
  142. package/templates/agent/codex/hooks.json +26 -0
  143. package/templates/agent/cursor/hooks.json +9 -0
  144. package/templates/agent/cursor/mcp.json +10 -0
  145. package/templates/agent/mapples.md +124 -0
  146. package/templates/agent/skills/mapples-conventions/SKILL.md +137 -0
  147. package/templates/agent/skills/mapples-design/SKILL.md +235 -0
  148. package/templates/agent/skills/mapples-design/reference/component-library.md +128 -0
  149. package/templates/agent/skills/mapples-design/reference/design-tokens.md +132 -0
  150. package/templates/agent/skills/mapples-design/reference/layout-contract.md +227 -0
  151. package/templates/agent/skills/mapples-design/reference/lint-checklist.md +51 -0
  152. package/templates/agent/skills/mapples-design/reference/plan.md +95 -0
  153. package/templates/agent/skills/mapples-design/reference/playbooks/empty-states.md +14 -0
  154. package/templates/agent/skills/mapples-design/reference/playbooks/forms-and-auth.md +22 -0
  155. package/templates/agent/skills/mapples-design/reference/playbooks/home-dashboard.md +14 -0
  156. package/templates/agent/skills/mapples-design/reference/playbooks/list-and-detail.md +18 -0
  157. package/templates/agent/skills/mapples-design/reference/playbooks/microcopy.md +20 -0
  158. package/templates/agent/skills/mapples-design/reference/playbooks/navigation-chrome.md +21 -0
  159. package/templates/agent/skills/mapples-design/reference/playbooks/onboarding-flow.md +15 -0
  160. package/templates/agent/skills/mapples-design/reference/playbooks/screen-flow-wiring.md +37 -0
  161. package/templates/agent/skills/mapples-design/reference/playbooks/settings-profile.md +16 -0
  162. package/templates/agent/skills/mapples-design/reference/playbooks/stats-and-progress.md +17 -0
  163. package/templates/agent/skills/mapples-design/reference/playbooks/visual-hierarchy.md +18 -0
  164. package/templates/agent/skills/mapples-design/reference/style-guides.md +92 -0
  165. package/templates/agent/skills/mapples-sync/SKILL.md +93 -0
  166. package/templates/app/_layout.tsx +9 -1
  167. package/templates/app.json +0 -1
  168. package/templates/gitignore +2 -0
  169. package/templates/mapples/sid.d.ts +6 -0
  170. package/templates/metro.config.js +11 -0
  171. package/templates/package.json +13 -10
@@ -1,32 +1,60 @@
1
1
  // `mapples sync` end-to-end (RFC-0002 Phase 5): extract -> local ops vs base ->
2
- // pull -> 3-way merge -> surgical file writes (deprecation on conflict) ->
3
- // POST -> 409 rebase loop -> c_ -> nd_ rewrite -> base + head update.
2
+ // pull -> place page files for the navigation -> 3-way merge -> surgical file
3
+ // writes (deprecation on conflict) -> POST -> 409 rebase loop -> c_ -> nd_
4
+ // rewrite -> base + head update.
5
+ //
6
+ // File layout (RFC-0002 §5): a page's code lives at its route file under
7
+ // routesDir while it is in the navigation, and under pagesDir otherwise; the
8
+ // navigation sync moves files between the two. Creator components live under
9
+ // componentsDir. Pages and components go through the same merge loop.
4
10
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
11
  import { dirname, join, relative } from 'node:path';
6
- import { extract, patch } from '@mapples/sid-ast';
12
+ import { adoptPropConditions } from '@mapples/codegen';
13
+ import { codemodAdopt, extract, patch, propOfCondition, upsertImports, } from '@mapples/sid-ast';
7
14
  import { isNetworkError } from '../api/client.js';
8
- import { buildEnvelope, commitWithTransportRetry, mintTxId } from '../api/commit.js';
15
+ import { buildEnvelope, commitWithTransportRetry, mintCid, mintTxId, resolveCids, } from '../api/commit.js';
9
16
  import { HeadMismatchError, JpatchError } from '../api/errors.js';
17
+ import { planTransactions } from '../api/txPlan.js';
10
18
  import { pullDelta, SnapshotRequired } from '../api/poll.js';
11
19
  import { fetchProjectState, } from '../api/objects.js';
12
- import { emitPage, mappletName } from '../codegen/emit.js';
13
- import { applyNodeOps } from '../merge/applyOps.js';
20
+ import { PRIMITIVE_MAPPING } from '../adopt/mappingTable.js';
21
+ import { emitComponent, emitPage, ROUTE_MARKER, withoutRouteMarker } from '../codegen/emit.js';
22
+ import { importNeedsFor, importNeedsUsedIn } from '../codegen/imports.js';
23
+ import { instanceLinker } from '../codegen/instances.js';
24
+ import { makeHunks, merge3, textHash } from '@mapples/textpatch';
14
25
  import { deprecateFile } from '../merge/deprecate.js';
15
- import { diffTree } from '../merge/diff.js';
16
- import { applyMergePatch, deepEqual } from '../merge/mergePatch.js';
26
+ import { diffTree, diffTreeStaged } from '../merge/diff.js';
27
+ import { buildMergePatch, deepEqual } from '../merge/mergePatch.js';
17
28
  import { threeWayMerge } from '../merge/threeWay.js';
18
29
  import { treeToEdits } from '../merge/toEdits.js';
19
30
  import { BaseStore } from '../project/baseStore.js';
20
- import { readConfig } from '../project/config.js';
31
+ import { DEFAULT_COMPONENTS_DIR, DEFAULT_ROUTES_DIR, readConfig, } from '../project/config.js';
32
+ import { readComponentsMap, writeComponentsMap, } from '../project/componentsMap.js';
21
33
  import { readHead, writeHead } from '../project/head.js';
22
34
  import { Outbox } from '../project/outbox.js';
23
- import { readPagesMap, writePagesMap } from '../project/pagesMap.js';
35
+ import { readPagesMap, writePagesMap, } from '../project/pagesMap.js';
24
36
  import { resolveAssetIds, syncAssets, writeAssetMap } from '../assets/assets.js';
37
+ import { mintEntityFile } from '../nav/placement.js';
25
38
  import { syncNavigation } from '../nav/routeTree.js';
26
39
  import { syncStyleFile } from '../style/theme.js';
27
40
  import { emptyReport } from '../ui/report.js';
41
+ import { sourceIdFor, } from '../types.js';
28
42
  const MAX_REBASE_RETRIES = 5;
29
- const MAX_OPS_PER_TX = 200;
43
+ /** A Source create carries a whole file (≤256 KiB); updates are hunks (A13). Fewer per transaction than node ops. */
44
+ const SOURCE_OPS_PER_TX = 10;
45
+ // buildRemoteModel needs the project root to read the stored head; carried as
46
+ // a non-enumerable tag so BaseSnapshot's shape stays unchanged for callers.
47
+ const ROOT_TAG = Symbol('projectRoot');
48
+ function baseWithRoot(base, root) {
49
+ Object.defineProperty(base, ROOT_TAG, { value: root, configurable: true, enumerable: false });
50
+ return base;
51
+ }
52
+ function rootOf(base) {
53
+ const root = base[ROOT_TAG];
54
+ if (!root)
55
+ throw new Error('internal: base snapshot missing project root tag');
56
+ return root;
57
+ }
30
58
  export async function runSyncEngine({ root, client }) {
31
59
  const report = emptyReport();
32
60
  const config = readConfig(root);
@@ -35,19 +63,33 @@ export async function runSyncEngine({ root, client }) {
35
63
  const head = readHead(root);
36
64
  // ---- Cold bootstrap ----
37
65
  if (!head || !baseStore.exists()) {
38
- return bootstrap(root, client, config.appDir, report);
66
+ return bootstrap(root, client, config, report);
39
67
  }
40
68
  // ---- Drain outbox ----
41
69
  let currentHead = head.head;
70
+ let currentSeq = head.seq;
42
71
  for (const entry of outbox.list()) {
72
+ const progress = newProgress(currentHead, currentSeq);
73
+ const entryTxs = planTransactions(entry.ops);
43
74
  try {
44
- const result = await commitLoop(client, currentHead, entry.ops, entry.txId, report);
45
- currentHead = result.head;
75
+ await pushChunked(client, entryTxs, entry.txId, progress, { rebaseInPlace: true, report });
76
+ currentHead = progress.head;
77
+ currentSeq = progress.seq;
46
78
  outbox.clear(entry.txId);
47
79
  report.notes.push(`drained outbox tx ${entry.txId} (${entry.ops.length} ops)`);
48
80
  }
49
81
  catch (err) {
50
82
  if (isNetworkError(err)) {
83
+ if (progress.committedOps > 0) {
84
+ // Part of a split entry landed. Keep only what is left, with the
85
+ // committed cids resolved, under a fresh txId — the old one is spent.
86
+ outbox.clear(entry.txId);
87
+ outbox.park({
88
+ txId: mintTxId(),
89
+ ops: resolveCids(entryTxs.flat().slice(progress.committedOps), progress.idMap),
90
+ parkedAt: entry.parkedAt,
91
+ });
92
+ }
51
93
  report.offline = true;
52
94
  report.notes.push('still offline — outbox kept');
53
95
  return report;
@@ -57,6 +99,15 @@ export async function runSyncEngine({ root, client }) {
57
99
  report.notes.push(`outbox tx ${entry.txId} dropped: ${err.message} (target changed under it — this is the one user-visible conflict class, RFC-0001 §7.1)`);
58
100
  continue;
59
101
  }
102
+ if (err instanceof JpatchError &&
103
+ (err.code === 'SOURCE_BASE_MISMATCH' || err.code === 'SOURCE_PATCH_INVALID')) {
104
+ // A parked code edit whose text Creator changed meanwhile (A13).
105
+ // Nothing is lost: the file still holds the local text, and the
106
+ // merge below re-derives the upload from base, file and remote.
107
+ outbox.clear(entry.txId);
108
+ report.notes.push(`outbox tx ${entry.txId} dropped: Creator changed this file meanwhile — your code edit is re-merged from the file on this sync`);
109
+ continue;
110
+ }
60
111
  throw err;
61
112
  }
62
113
  }
@@ -64,7 +115,7 @@ export async function runSyncEngine({ root, client }) {
64
115
  const base = baseStore.readAll();
65
116
  let remote;
66
117
  try {
67
- remote = await buildRemoteModel(client, base, currentHead, report);
118
+ remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
68
119
  }
69
120
  catch (err) {
70
121
  if (isNetworkError(err)) {
@@ -73,41 +124,67 @@ export async function runSyncEngine({ root, client }) {
73
124
  throw err;
74
125
  }
75
126
  // ---- Merge + write + push (with 409 rebase loop) ----
76
- const txId = mintTxId();
127
+ let txId = mintTxId();
77
128
  let attempt = 0;
78
129
  for (;;) {
79
- const { pushOps, mergedBase, pagesMap } = await mergeAndWrite(root, client, config, base, remote, report);
80
- if (pushOps.length === 0) {
81
- finalize(root, baseStore, mergedBase, pagesMap, remote.head, remote.seq);
130
+ const merged = await mergeAndWrite(root, client, config, base, remote, report);
131
+ const { mergedBase, pagesMap, componentsMap, sourceUploads } = merged;
132
+ // Planned before anything is sent: a placement group that cannot fit one
133
+ // transaction stops the push here (RFC-0001 §5.2). `pushOps` is the send
134
+ // order, which is what `progress.committedOps` counts against.
135
+ const pushTxs = [...planTransactions(merged.pushOps), ...planTransactions(merged.deferredOps)];
136
+ const pushOps = pushTxs.flat();
137
+ // Source text goes out AFTER the node ops (its `$sid`s must be server
138
+ // ids, which the node push mints), as its own transaction(s).
139
+ let sourceOps = [];
140
+ if (pushOps.length === 0 && sourceUploads.size === 0) {
141
+ finalize(root, baseStore, mergedBase, pagesMap, componentsMap, remote.head, remote.seq);
82
142
  report.head = remote.head;
83
143
  return report;
84
144
  }
85
- if (pushOps.length > MAX_OPS_PER_TX) {
145
+ if (pushTxs.length > 1) {
86
146
  // Split into sequential transactions (RFC-0001 §5.1 TX_TOO_LARGE discipline).
87
- report.notes.push(`push split into ${Math.ceil(pushOps.length / MAX_OPS_PER_TX)} transactions`);
147
+ report.notes.push(`push split into ${pushTxs.length} transactions`);
88
148
  }
149
+ const progress = newProgress(remote.head, remote.seq);
89
150
  try {
90
- let resultHead = remote.head;
91
- let resultSeq = remote.seq;
92
- let idMap = {};
93
- let cursorBase = remote.head;
94
- for (let i = 0; i < pushOps.length; i += MAX_OPS_PER_TX) {
95
- const chunk = pushOps.slice(i, i + MAX_OPS_PER_TX);
96
- const chunkTx = i === 0 ? txId : mintTxId();
97
- const result = await commitWithTransportRetry(client, buildEnvelope(client, { base: cursorBase, ops: chunk, txId: chunkTx }));
98
- resultHead = result.head;
99
- resultSeq = result.seq;
100
- cursorBase = result.head;
101
- idMap = { ...idMap, ...result.idMap };
102
- if (result.warnings?.length) {
103
- for (const w of result.warnings)
104
- report.notes.push(`server warning: ${w}`);
151
+ if (pushOps.length > 0) {
152
+ await pushChunked(client, pushTxs, txId, progress, {
153
+ rebaseInPlace: false,
154
+ report,
155
+ // Rewrite as each chunk lands, so files and base never hold a cid the
156
+ // server has already resolved: a re-merge or a park after a later
157
+ // chunk fails then sees the committed nodes by their real ids.
158
+ onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
159
+ });
160
+ report.pushedOps += pushOps.length;
161
+ }
162
+ sourceOps = sourceOpsFor(mergedBase, remote, sourceUploads, progress.idMap);
163
+ try {
164
+ for (let i = 0; i < sourceOps.length; i += SOURCE_OPS_PER_TX) {
165
+ await pushChunked(client, [sourceOps.slice(i, i + SOURCE_OPS_PER_TX)], pushOps.length === 0 && i === 0 ? txId : mintTxId(), progress, { rebaseInPlace: false, report });
166
+ }
167
+ if (sourceOps.length > 0) {
168
+ report.pushedOps += sourceOps.length;
169
+ report.notes.push(`uploaded the source text of ${sourceOps.length} file(s) to Creator`);
105
170
  }
106
171
  }
107
- report.pushedOps += pushOps.length;
108
- rewriteIds(root, mergedBase, pagesMap, idMap, report);
109
- finalize(root, baseStore, mergedBase, pagesMap, resultHead, resultSeq);
110
- report.head = resultHead;
172
+ catch (err) {
173
+ if (!(err instanceof JpatchError && err.code === 'UNKNOWN_ENTITY'))
174
+ throw err;
175
+ // A Creator server from before A12 rejects the entity. The node ops
176
+ // already landed; the code stays local and is offered again once the
177
+ // server stores source text — never silently.
178
+ for (const id of sourceUploads) {
179
+ const entry = mergedBase.sources.get(progress.idMap[id] ?? id);
180
+ if (entry)
181
+ entry.onServer = false;
182
+ }
183
+ sourceOps = [];
184
+ report.notes.push('this Creator server does not store source text yet — the code around your elements stays local until it does');
185
+ }
186
+ finalize(root, baseStore, mergedBase, pagesMap, componentsMap, progress.head, progress.seq);
187
+ report.head = progress.head;
111
188
  return report;
112
189
  }
113
190
  catch (err) {
@@ -116,18 +193,31 @@ export async function runSyncEngine({ root, client }) {
116
193
  if (attempt >= MAX_REBASE_RETRIES) {
117
194
  throw new Error(`sync gave up after ${MAX_REBASE_RETRIES} rebase attempts — the project is changing faster than we can rebase; re-run \`mapples sync\``);
118
195
  }
119
- report.notes.push(`rebase ${attempt}: HEAD moved to ${err.head} (${err.delta.ops.length} new ops)`);
120
- applyDeltaToRemote(remote, err.delta.ops, err.head, err.delta.seq, base, report);
121
- continue; // re-merge against the advanced remote, retry SAME txId
196
+ if (progress.committedOps > 0) {
197
+ // Earlier chunks landed under `txId`; replaying it would hand back
198
+ // their result instead of committing the re-merged ops.
199
+ report.pushedOps += progress.committedOps;
200
+ txId = mintTxId();
201
+ }
202
+ report.notes.push(`rebase ${attempt}: HEAD moved to ${err.head} — refetching state`);
203
+ remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
204
+ continue; // re-merge against the advanced remote, retry the same txId unless it is spent
122
205
  }
123
206
  if (isNetworkError(err)) {
124
- // Files already show the merged view; park the ops, adopt the remote
125
- // head so the drain pushes exactly these ops next time.
126
- outbox.park({ txId, ops: pushOps, parkedAt: new Date().toISOString() });
127
- report.pushedOps = pushOps.length;
207
+ // Files already show the merged view; park what did not land (with
208
+ // the committed chunks' cids resolved) and adopt the remote head so
209
+ // the drain pushes exactly these ops next time.
210
+ // Source ops not yet computed (offline during the node push) are
211
+ // simply recomputed next time: the files still differ from the base.
212
+ const allOps = [...pushOps, ...sourceOps];
213
+ outbox.park({
214
+ txId: progress.committedOps > 0 ? mintTxId() : txId,
215
+ ops: resolveCids(allOps.slice(progress.committedOps), progress.idMap),
216
+ parkedAt: new Date().toISOString(),
217
+ });
218
+ report.pushedOps = allOps.length;
128
219
  report.offline = true;
129
- const { mergedBase: remoteOnlyBase, pagesMap } = remoteAsBase(base, remote);
130
- finalize(root, baseStore, remoteOnlyBase, { ...readPagesMap(root), ...pagesMap }, remote.head, remote.seq);
220
+ finalize(root, baseStore, remoteAsBase(remote), readPagesMap(root), readComponentsMap(root), remote.head, remote.seq);
131
221
  return report;
132
222
  }
133
223
  throw err;
@@ -135,40 +225,70 @@ export async function runSyncEngine({ root, client }) {
135
225
  }
136
226
  }
137
227
  // ---------------------------------------------------------------- bootstrap
138
- async function bootstrap(root, client, appDir, report) {
228
+ async function bootstrap(root, client, config, report) {
139
229
  const boot = await client.bootstrap();
140
230
  const state = await fetchProjectState(client, boot);
141
231
  const baseStore = new BaseStore(root);
142
232
  const pagesMap = readPagesMap(root);
143
- const usedFiles = new Set(Object.values(pagesMap).map((m) => m.file));
233
+ // Placement first: every page gets its file position from the navigation.
234
+ const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, state.pages, pagesMap, state.navigation, state.navigation, report);
235
+ const componentsMap = readComponentsMap(root);
236
+ placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, state.components, componentsMap);
237
+ const linker = instanceLinker(componentsMap, state.components);
238
+ // A container with stored source text (A12) materializes from that text —
239
+ // hooks and handlers written in the Creator's editor included — patched to
240
+ // its current tree; the rest is generated.
241
+ const targets = [];
144
242
  for (const page of state.pages.values()) {
145
- let mapping = pagesMap[page.id];
146
- if (!mapping) {
147
- mapping = {
148
- file: mintFilePath(appDir, page.name, usedFiles),
149
- name: page.name,
150
- route: page.route,
151
- };
152
- pagesMap[page.id] = mapping;
153
- usedFiles.add(mapping.file);
154
- }
155
- const filePath = join(root, mapping.file);
243
+ const mapping = pagesMap[page.id];
244
+ if (!mapping)
245
+ throw new Error(`internal: page ${page.id} was not placed`);
246
+ const tree = linker.unlink(page.root, mapping.file, warnInto(report, mapping.file));
247
+ targets.push({
248
+ id: page.id,
249
+ file: mapping.file,
250
+ emit: async () => renderFromSource(state.sources.get(page.id) ?? null, tree, mapping.file, report, page.state) ?? emitPage(emitInput(page.name, tree, mapping, page.state), join(root, mapping.file)),
251
+ });
252
+ }
253
+ for (const component of state.components.values()) {
254
+ const mapping = componentsMap[component.id];
255
+ const tree = linker.unlink(component.root, mapping.file, warnInto(report, mapping.file));
256
+ targets.push({
257
+ id: component.id,
258
+ file: mapping.file,
259
+ emit: async () => renderFromSource(state.sources.get(component.id) ?? null, tree, mapping.file, report, component.state) ??
260
+ emitComponent({ name: component.name, root: tree, states: component.state }, join(root, mapping.file)),
261
+ });
262
+ }
263
+ const baseSources = new Map();
264
+ for (const target of targets) {
265
+ const filePath = join(root, target.file);
156
266
  if (existsSync(filePath)) {
157
267
  // P0: never overwrite. The file's content will be merged on the next sync.
158
268
  report.files.push({
159
- file: mapping.file,
269
+ file: target.file,
160
270
  action: 'unchanged',
161
271
  detail: 'exists — left untouched',
162
272
  });
163
- continue;
164
273
  }
165
- mkdirSync(dirname(filePath), { recursive: true });
166
- writeFileSync(filePath, await emitPage({ name: page.name, root: page.root }, filePath));
167
- report.files.push({ file: mapping.file, action: 'created' });
274
+ else {
275
+ mkdirSync(dirname(filePath), { recursive: true });
276
+ writeFileSync(filePath, await target.emit());
277
+ report.files.push({ file: target.file, action: 'created' });
278
+ }
279
+ // The text 3-way's base: the server's text where it has one, else the
280
+ // file as it stands (an untouched pre-existing file included — its own
281
+ // code then reads as a local change and uploads on the next sync).
282
+ const remoteSource = state.sources.get(target.id) ?? null;
283
+ baseSources.set(target.id, {
284
+ id: remoteSource?.id ?? sourceIdFor(target.id),
285
+ of: target.id,
286
+ lang: remoteSource?.lang ?? 'tsx',
287
+ text: remoteSource?.text ?? readFileSync(filePath, 'utf8'),
288
+ onServer: remoteSource !== null,
289
+ });
168
290
  }
169
- const config = readConfig(root);
170
291
  const styleResult = syncStyleFile(root, state.style, state.style, report);
171
- const navResult = syncNavigation(root, config.routesDir ?? 'app/(mapples)', pagesMap, state.navigation, state.navigation, report);
172
292
  const assetsResult = await syncAssets(root, client, state.assets, report);
173
293
  if (assetsResult.pushOps.length > 0) {
174
294
  report.notes.push('local assets found — run `mapples sync` again to upload them');
@@ -179,211 +299,301 @@ async function bootstrap(root, client, appDir, report) {
179
299
  navigation: navResult.mergedNavigation,
180
300
  style: styleResult.mergedStyle,
181
301
  assets: state.assets,
302
+ sources: baseSources,
182
303
  });
304
+ baseStore.writeStamp(state.head);
183
305
  writePagesMap(root, pagesMap);
306
+ writeComponentsMap(root, componentsMap);
184
307
  writeHead(root, { head: state.head, seq: state.seq });
185
308
  report.head = state.head;
186
- report.notes.push(`bootstrapped ${state.pages.size} page(s) @ ${state.head}`);
309
+ report.notes.push(`bootstrapped ${state.pages.size} page(s), ${state.components.size} component(s) @ ${state.head}`);
187
310
  return report;
188
311
  }
189
- function mintFilePath(appDir, pageName, used) {
190
- const baseName = mappletName(pageName);
191
- let file = join(appDir, `${baseName}.tsx`);
192
- let counter = 2;
193
- while (used.has(file)) {
194
- file = join(appDir, `${baseName}${counter}.tsx`);
195
- counter += 1;
312
+ /** Every Creator component gets a file under componentsDir; existing mappings are kept. */
313
+ function placeComponents(componentsDir, components, componentsMap) {
314
+ const used = new Set(Object.values(componentsMap).map((m) => m.file));
315
+ for (const [id, component] of components) {
316
+ if (componentsMap[id])
317
+ continue;
318
+ const file = mintEntityFile(componentsDir, component.name, used);
319
+ used.add(file);
320
+ componentsMap[id] = { file, name: component.name };
321
+ }
322
+ }
323
+ /** The route marker for a routed page's (re)generated file. */
324
+ function emitInput(name, root, mapping, states) {
325
+ const input = { name, root, ...(states ? { states } : {}) };
326
+ return mapping.routeId ? { ...input, route: { id: mapping.routeId, name } } : input;
327
+ }
328
+ /**
329
+ * A container's states after a file is merged in: the Creator's own states
330
+ * as the server has them, and the prop states the file's code declares
331
+ * (`isActive && …`) in place of the prop states the server held.
332
+ */
333
+ function withFileStates(remote, file) {
334
+ const out = {};
335
+ for (const [key, state] of Object.entries(remote)) {
336
+ if (propOfCondition(state.condition) === null)
337
+ out[key] = state;
196
338
  }
197
- return file;
339
+ for (const [key, state] of Object.entries(file))
340
+ out[key] = { id: key, ...state };
341
+ return out;
198
342
  }
199
343
  // ------------------------------------------------------------- remote model
200
- async function buildRemoteModel(client, base, sinceHead, report) {
344
+ /** The base's Source texts the server holds, keyed by container — a fetch skip-list. */
345
+ function knownSources(base) {
346
+ return new Map([...base.sources]
347
+ .filter(([, source]) => source.onServer)
348
+ .map(([id, { onServer: _onServer, ...source }]) => [id, source]));
349
+ }
350
+ async function buildRemoteModel(client, base, sinceSeq, report) {
351
+ // The delta is used only to detect change and report op counts. The remote
352
+ // MODEL comes from the manifest: blobs are content-addressed, so the walk
353
+ // costs only what changed (a Source whose base text still hashes to the
354
+ // index entry is not fetched again).
355
+ let head;
201
356
  try {
202
- const delta = await pullDelta(client, sinceHead);
357
+ const delta = await pullDelta(client, sinceSeq);
203
358
  report.pulledOps = delta.ops.length;
204
- const model = {
359
+ head = delta.head;
360
+ }
361
+ catch (err) {
362
+ if (!(err instanceof SnapshotRequired))
363
+ throw err;
364
+ report.notes.push('too far behind — cold re-bootstrap of remote state');
365
+ head = '';
366
+ }
367
+ const storedHead = readHead(rootOf(base)) ?? null;
368
+ const stamped = new BaseStore(rootOf(base)).readStamp();
369
+ if (head !== '' &&
370
+ storedHead &&
371
+ head === storedHead.head &&
372
+ stamped === storedHead.head &&
373
+ report.pulledOps === 0) {
374
+ // Nothing changed server-side and the base was written at this very
375
+ // head: the base IS the remote state.
376
+ return {
205
377
  pages: structuredClone(base.pages),
378
+ components: structuredClone(base.components),
206
379
  navigation: structuredClone(base.navigation),
207
380
  style: structuredClone(base.style),
208
381
  assets: structuredClone(base.assets),
209
- head: delta.head,
210
- seq: delta.seq,
382
+ sources: new Map([...base.sources]
383
+ .filter(([, source]) => source.onServer)
384
+ .map(([id, { onServer: _onServer, ...source }]) => [id, source])),
385
+ head: storedHead.head,
386
+ seq: storedHead.seq,
211
387
  };
212
- applyDeltaToRemote(model, delta.ops, delta.head, delta.seq, base, report);
213
- return model;
214
388
  }
215
- catch (err) {
216
- if (err instanceof SnapshotRequired) {
217
- report.notes.push('too far behind — cold re-bootstrap of remote state');
218
- const boot = await client.bootstrap();
219
- const state = await fetchProjectState(client, boot);
220
- return {
221
- pages: state.pages,
222
- navigation: state.navigation,
223
- style: state.style,
224
- assets: state.assets,
225
- head: state.head,
226
- seq: state.seq,
227
- };
228
- }
229
- throw err;
230
- }
231
- }
232
- function applyDeltaToRemote(model, ops, head, seq, base, report) {
233
- model.head = head;
234
- model.seq = seq;
235
- const nodeOps = ops.filter((o) => o.entity === 'RenderNode');
236
- for (const page of model.pages.values()) {
237
- const { root } = applyNodeOps(page.root, nodeOps);
238
- if (root)
239
- page.root = root;
240
- else
241
- model.pages.delete(page.id);
242
- }
243
- for (const op of ops) {
244
- switch (op.entity) {
245
- case 'RenderNode':
246
- break; // handled above
247
- case 'Page': {
248
- if (op.op === 'create') {
249
- const data = op.data;
250
- const id = op.id ?? op.cid;
251
- if (!id)
252
- throw new Error('Page create op without id');
253
- const tree = buildTreeFromOps(nodeOps, data.root);
254
- if (!tree) {
255
- report.notes.push(`pulled Page ${id} but its root ${data.root} is not in the delta — skipping`);
256
- break;
257
- }
258
- model.pages.set(id, { id, name: data.name, route: data.route, root: tree });
259
- }
260
- else if (op.op === 'update' && op.id) {
261
- const page = model.pages.get(op.id);
262
- if (page && op.patch) {
263
- const patched = applyMergePatch({ name: page.name, route: page.route }, op.patch);
264
- page.name = patched.name;
265
- page.route = patched.route;
266
- }
267
- }
268
- else if (op.op === 'delete' && op.id) {
269
- model.pages.delete(op.id);
270
- }
271
- break;
272
- }
273
- case 'NavigationRoute': {
274
- const nav = model.navigation ?? { routes: [], layouts: {} };
275
- if (op.op === 'create') {
276
- const id = op.id ?? op.cid;
277
- if (id)
278
- nav.routes.push({ id, ...op.data });
279
- }
280
- else if (op.op === 'update' && op.id && op.patch) {
281
- const idx = nav.routes.findIndex((r) => r.id === op.id);
282
- if (idx !== -1)
283
- nav.routes[idx] = applyMergePatch(nav.routes[idx], op.patch);
284
- }
285
- else if (op.op === 'delete' && op.id) {
286
- nav.routes = nav.routes.filter((r) => r.id !== op.id);
287
- }
288
- model.navigation = nav;
289
- break;
290
- }
291
- case 'NavigationDocument':
292
- if (op.patch)
293
- model.navigation = applyMergePatch(model.navigation ?? { routes: [], layouts: {} }, op.patch);
294
- break;
295
- case 'StyleConfig':
296
- if (op.patch)
297
- model.style = applyMergePatch(model.style ?? { theme: {}, typography: {}, sizing: {} }, op.patch);
298
- break;
299
- case 'Theme':
300
- case 'Typography':
301
- case 'Sizing': {
302
- const key = op.entity.toLowerCase();
303
- const style = model.style ?? { theme: {}, typography: {}, sizing: {} };
304
- if (op.patch) {
305
- model.style = { ...style, [key]: applyMergePatch(style[key], op.patch) };
306
- }
307
- break;
308
- }
309
- case 'Asset': {
310
- const assets = model.assets ?? {};
311
- const id = op.id ?? op.cid;
312
- if (op.op === 'delete' && op.id)
313
- delete assets[op.id];
314
- else if (op.op === 'create' && id)
315
- assets[id] = op.data;
316
- else if (op.op === 'update' && op.id && op.patch) {
317
- assets[op.id] = applyMergePatch(assets[op.id], op.patch);
318
- }
319
- model.assets = assets;
320
- break;
321
- }
322
- default:
323
- report.notes.push(`pulled op for unhandled entity ${op.entity} — recorded in base only`);
324
- }
325
- }
326
- void base;
327
- }
328
- function buildTreeFromOps(nodeOps, rootId) {
329
- const { root, orphans } = applyNodeOps(null, nodeOps);
330
- if (root && root.sid === rootId)
331
- return root;
332
- return orphans.get(rootId) ?? null;
389
+ const boot = await client.bootstrap();
390
+ const state = await fetchProjectState(client, boot, knownSources(base));
391
+ return {
392
+ pages: state.pages,
393
+ components: state.components,
394
+ navigation: state.navigation,
395
+ style: state.style,
396
+ assets: state.assets,
397
+ sources: state.sources,
398
+ head: state.head,
399
+ seq: state.seq,
400
+ };
333
401
  }
402
+ const PAGE_KIND = { label: 'page', entity: 'Page' };
403
+ const COMPONENT_KIND = { label: 'component', entity: 'Component' };
334
404
  async function mergeAndWrite(root, client, config, base, remote, report) {
335
- const appDir = config.appDir;
336
405
  const pagesMap = readPagesMap(root);
337
- const usedFiles = new Set(Object.values(pagesMap).map((m) => m.file));
406
+ const componentsMap = readComponentsMap(root);
338
407
  const pushOps = [];
408
+ const deferredOps = [];
409
+ const sourceUploads = new Set();
339
410
  const mergedBase = {
340
411
  pages: new Map(),
341
- components: base.components,
412
+ components: new Map(),
342
413
  navigation: remote.navigation,
343
414
  style: remote.style,
344
415
  assets: remote.assets,
416
+ sources: new Map(),
345
417
  };
346
418
  // Reset per-attempt report fields (the rebase loop re-runs this).
347
419
  report.files = [];
348
420
  report.conflicts = [];
349
421
  report.warnings = [];
350
- const pageIds = new Set([...base.pages.keys(), ...remote.pages.keys()]);
351
- for (const pageId of pageIds) {
352
- const basePage = base.pages.get(pageId) ?? null;
353
- const remotePage = remote.pages.get(pageId) ?? null;
354
- let mapping = pagesMap[pageId];
422
+ // ---- Navigation first: it decides where every page file lives ----
423
+ const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, remote.pages, pagesMap, base.navigation, remote.navigation, report);
424
+ pushOps.push(...navResult.pushOps);
425
+ mergedBase.navigation = navResult.mergedNavigation;
426
+ placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, remote.components, componentsMap);
427
+ // Component uuids come from the remote model; a page's instances link to them.
428
+ const linker = instanceLinker(componentsMap, remote.components);
429
+ await mergeFiles({
430
+ root,
431
+ kind: PAGE_KIND,
432
+ base: base.pages,
433
+ remote: remote.pages,
434
+ mapping: pagesMap,
435
+ linker,
436
+ emit: (entity, mapping, tree, filePath) => emitPage(emitInput(entity.name, tree, mapping, entity.state), filePath),
437
+ mergedOut: mergedBase.pages,
438
+ baseSources: base.sources,
439
+ remoteSources: remote.sources,
440
+ sourcesOut: mergedBase.sources,
441
+ sourceUploads,
442
+ pushOps,
443
+ deferredOps,
444
+ report,
445
+ });
446
+ await mergeFiles({
447
+ root,
448
+ kind: COMPONENT_KIND,
449
+ base: base.components,
450
+ remote: remote.components,
451
+ mapping: componentsMap,
452
+ linker,
453
+ emit: (entity, _mapping, tree, filePath) => emitComponent({ name: entity.name, root: tree, states: entity.state }, filePath),
454
+ mergedOut: mergedBase.components,
455
+ baseSources: base.sources,
456
+ remoteSources: remote.sources,
457
+ sourcesOut: mergedBase.sources,
458
+ sourceUploads,
459
+ pushOps,
460
+ deferredOps,
461
+ report,
462
+ });
463
+ // ---- Non-page entities: style, assets ----
464
+ const styleResult = syncStyleFile(root, base.style, remote.style, report);
465
+ pushOps.push(...styleResult.pushOps);
466
+ mergedBase.style = styleResult.mergedStyle;
467
+ const assetsResult = await syncAssets(root, client, remote.assets, report);
468
+ pushOps.push(...assetsResult.pushOps);
469
+ mergedBase.assets = assetsResult.mergedAssets;
470
+ return { pushOps, deferredOps, mergedBase, pagesMap, componentsMap, sourceUploads };
471
+ }
472
+ /**
473
+ * The 3-way file merge for one entity kind: extract the file, merge against
474
+ * base and remote, write the merged view (patch in place, or regenerate),
475
+ * deprecate on conflict, and collect the ops that push local changes.
476
+ */
477
+ async function mergeFiles(args) {
478
+ const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, baseSources, remoteSources, sourcesOut, sourceUploads, pushOps, deferredOps, report, } = args;
479
+ /**
480
+ * The file's text after this sync becomes the next base. It goes to the
481
+ * server when a stored text exists and differs from the file (the file
482
+ * always wins: the merge above already folded the Creator's side in), or
483
+ * when the developer changed the code of a file the server has no text for.
484
+ */
485
+ const recordSource = (id, remoteSource, text, codeChanged) => {
486
+ const upload = remoteSource !== null ? remoteSource.text !== text : codeChanged;
487
+ if (upload)
488
+ sourceUploads.add(id);
489
+ sourcesOut.set(id, {
490
+ id: remoteSource?.id ?? sourceIdFor(id),
491
+ of: id,
492
+ lang: remoteSource?.lang ?? 'tsx',
493
+ text,
494
+ onServer: remoteSource !== null || upload,
495
+ });
496
+ };
497
+ /** A container's file text: its stored source patched to `tree`, else generated. */
498
+ const fileTextFor = async (entity, mapping, remoteSource, tree, filePath) => renderFromSource(remoteSource, tree, mapping.file, report, entity.state) ??
499
+ (await emit(entity, mapping, tree, filePath));
500
+ const ids = new Set([...base.keys(), ...remote.keys()]);
501
+ for (const id of ids) {
502
+ const baseEntity = base.get(id) ?? null;
503
+ const remoteEntity = remote.get(id) ?? null;
504
+ const mapping = mappings[id];
355
505
  if (!mapping) {
356
- const name = remotePage?.name ?? basePage?.name ?? pageId;
357
- mapping = {
358
- file: mintFilePath(appDir, name, usedFiles),
359
- name,
360
- route: remotePage?.route ?? basePage?.route ?? '/',
361
- };
362
- pagesMap[pageId] = mapping;
363
- usedFiles.add(mapping.file);
506
+ // Known to the base only and never written to disk: nothing to reconcile.
507
+ if (remoteEntity)
508
+ throw new Error(`internal: ${kind.label} ${id} was not placed`);
509
+ continue;
364
510
  }
365
511
  const filePath = join(root, mapping.file);
366
512
  const fileExists = existsSync(filePath);
513
+ const baseSource = baseSources.get(id) ?? null;
514
+ const remoteSource = remoteSources.get(id) ?? null;
367
515
  // local = fresh extract, with NON_LITERAL props refilled from base.
368
516
  let local;
517
+ // The same tree as the file reads it (linked instances still Customs):
518
+ // what edits to the file are computed against.
519
+ let localFile = null;
369
520
  let fileSource = null;
521
+ /** The prop states the file's code declares (null: no file was read). */
522
+ let fileStates = null;
523
+ /**
524
+ * The file as the developer left it, after import healing (a stripped
525
+ * import is an interrupted sync's damage, not a code edit) and before
526
+ * tagging (an element added without a `$sid` IS one).
527
+ */
528
+ let originalSource = null;
370
529
  if (fileExists) {
371
530
  fileSource = readFileSync(filePath, 'utf8');
531
+ // Pre-extraction import healing: a file left behind by an interrupted
532
+ // sync may use elements its import block never gained. Without the
533
+ // import, extraction reads primitives as local Customs and the merge
534
+ // would push that misreading to the server. The BASE tree knows the true
535
+ // types for every existing sid — and names elements the developer has
536
+ // since deleted, whose imports must stay gone.
537
+ if (baseEntity) {
538
+ const healed = upsertImports(fileSource, importNeedsUsedIn(fileSource, importNeedsFor(linker.unlink(baseEntity.root, mapping.file, () => undefined))));
539
+ if (healed !== fileSource) {
540
+ fileSource = healed;
541
+ writeFileSync(filePath, healed);
542
+ report.notes.push(`${mapping.file}: restored missing imports before extraction`);
543
+ }
544
+ }
545
+ originalSource = fileSource;
546
+ // An element the developer added without a `$sid` is invisible to
547
+ // extraction — and so to the Creator — for as long as it stays untagged.
548
+ // Tag it the way adoption did the file: react-native primitives swapped
549
+ // and their props translated, anything else an opaque Custom node.
550
+ const tagged = codemodAdopt(fileSource, {
551
+ mapping: PRIMITIVE_MAPPING,
552
+ mintSid: mintCid,
553
+ isLinked: (origin) => linker.isLinked(origin, mapping.file),
554
+ });
555
+ if (tagged.adopted.length > 0) {
556
+ fileSource = tagged.source;
557
+ writeFileSync(filePath, fileSource);
558
+ const names = tagged.adopted.map((a) => a.kind === 'custom' ? `${a.originalName} (Custom)` : a.originalName);
559
+ report.notes.push(`${mapping.file}: tagged ${tagged.adopted.length} new element(s) — ${names.join(', ')}`);
560
+ for (const skip of tagged.skipped) {
561
+ report.warnings.push({
562
+ kind: 'UNKNOWN_ELEMENT_WITH_SID',
563
+ detail: `${mapping.file}: ${skip.reason}`,
564
+ });
565
+ }
566
+ }
372
567
  const extracted = extract(fileSource);
373
568
  for (const w of extracted.warnings)
374
569
  report.warnings.push({ ...w, file: mapping.file });
375
570
  local = extracted.dom;
376
- if (local && basePage)
377
- fillNonLiteralProps(local, basePage.root, extracted.warnings);
571
+ fileStates = extracted.states;
572
+ if (local) {
573
+ // A hand-written `$sid` that is neither a server id nor a ULID-shaped
574
+ // `c_` placeholder fails the server's envelope validation and takes
575
+ // the whole push down with it. Mint the placeholder here instead.
576
+ const renamed = normalizeClientIds(local);
577
+ if (renamed.size > 0) {
578
+ for (const [from, to] of renamed)
579
+ fileSource = fileSource.split(`"${from}"`).join(`"${to}"`);
580
+ writeFileSync(filePath, fileSource);
581
+ report.notes.push(`${mapping.file}: minted client id(s) for ${[...renamed.keys()].join(', ')} (a new element's $sid must be c_<ULID>)`);
582
+ }
583
+ localFile = structuredClone(local);
584
+ linker.link(local, mapping.file, warnInto(report, mapping.file));
585
+ }
586
+ if (local && baseEntity)
587
+ fillNonLiteralProps(local, baseEntity.root, extracted.warnings);
378
588
  }
379
589
  else {
380
- local = basePage?.root ?? null; // no file -> treat as unchanged
381
- if (basePage) {
590
+ local = baseEntity?.root ?? null; // no file -> treat as unchanged
591
+ if (baseEntity) {
382
592
  report.notes.push(`${mapping.file} is missing — regenerating from the merged canvas state`);
383
593
  }
384
594
  }
385
- const baseRoot = basePage?.root ?? null;
386
- const remoteRoot = remotePage?.root ?? null;
595
+ const baseRoot = baseEntity?.root ?? null;
596
+ const remoteRoot = remoteEntity?.root ?? null;
387
597
  const { merged, conflicts } = threeWayMerge(baseRoot, local, remoteRoot);
388
598
  if (conflicts.length > 0) {
389
599
  let deprecatedTo;
@@ -391,89 +601,251 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
391
601
  deprecatedTo = relative(root, deprecateFile(filePath));
392
602
  for (const c of conflicts)
393
603
  report.conflicts.push({ ...c, file: mapping.file, deprecatedTo });
394
- if (remotePage) {
604
+ if (remoteEntity) {
395
605
  mkdirSync(dirname(filePath), { recursive: true });
396
- writeFileSync(filePath, await emitPage({ name: remotePage.name, root: remotePage.root }, filePath));
606
+ const regenerated = await fileTextFor(remoteEntity, mapping, remoteSource, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath);
607
+ writeFileSync(filePath, regenerated);
608
+ recordSource(id, remoteSource, regenerated, false);
397
609
  report.files.push({
398
610
  file: mapping.file,
399
611
  action: 'deprecated',
400
612
  detail: `conflict — your version kept at ${deprecatedTo ?? '(no file)'}; regenerated from Creator`,
401
613
  });
402
- mergedBase.pages.set(pageId, remotePage);
403
- mapping.name = remotePage.name;
404
- mapping.route = remotePage.route;
614
+ mergedOut.set(id, remoteEntity);
615
+ mapping.name = remoteEntity.name;
405
616
  }
406
617
  else {
407
618
  report.files.push({
408
619
  file: mapping.file,
409
620
  action: 'deprecated',
410
- detail: 'page deleted in Creator; your local edits kept in the .old file',
621
+ detail: `${kind.label} deleted in Creator; your local edits kept in the .old file`,
411
622
  });
412
- delete pagesMap[pageId];
623
+ delete mappings[id];
413
624
  }
414
625
  continue; // keepLocal ops for this file are DROPPED from the push
415
626
  }
416
- if (remotePage === null) {
627
+ if (remoteEntity === null) {
417
628
  // Deleted remotely, no local edits (else it would have conflicted).
418
629
  if (fileExists) {
419
630
  const deprecatedTo = relative(root, deprecateFile(filePath));
420
631
  report.files.push({
421
632
  file: mapping.file,
422
633
  action: 'deprecated',
423
- detail: `page deleted in Creator — file kept at ${deprecatedTo}`,
634
+ detail: `${kind.label} deleted in Creator — file kept at ${deprecatedTo}`,
424
635
  });
425
636
  }
426
- delete pagesMap[pageId];
637
+ delete mappings[id];
427
638
  continue;
428
639
  }
429
640
  if (merged === null) {
430
- // Locally deleted page tree, remote unchanged: push the deletion.
431
- pushOps.push({ op: 'delete', entity: 'RenderNode', id: remotePage.root.sid, cascade: true });
432
- pushOps.push({ op: 'delete', entity: 'Page', id: pageId });
641
+ // Locally deleted tree, remote unchanged: push the deletion.
642
+ pushOps.push({
643
+ op: 'delete',
644
+ entity: 'RenderNode',
645
+ id: remoteEntity.root.sid,
646
+ cascade: true,
647
+ });
648
+ pushOps.push({ op: 'delete', entity: kind.entity, id });
433
649
  report.files.push({
434
650
  file: mapping.file,
435
651
  action: 'updated',
436
652
  detail: 'local deletion pushed to Creator',
437
653
  });
438
- delete pagesMap[pageId];
654
+ delete mappings[id];
439
655
  continue;
440
656
  }
441
- // Write the merged view into the file.
442
- if (fileExists && fileSource !== null && local !== null) {
443
- const edits = treeToEdits(local, merged);
444
- if (edits.length > 0) {
445
- const { source: patched, warnings } = patch(fileSource, edits);
446
- for (const w of warnings)
447
- report.warnings.push({ ...w, file: mapping.file });
448
- writeFileSync(filePath, patched);
449
- report.files.push({ file: mapping.file, action: 'updated' });
657
+ // The file owns the states its prop tests declare; the Creator the rest.
658
+ const remoteStates = remoteEntity.state ?? {};
659
+ const mergedStates = fileStates === null ? remoteStates : withFileStates(remoteStates, fileStates);
660
+ const mergedEntity = { ...remoteEntity, state: mergedStates };
661
+ // Write the merged view into the file — in the file's language.
662
+ const mergedFile = linker.unlink(merged, mapping.file, warnInto(report, mapping.file));
663
+ /** The file's text after this sync. */
664
+ let written;
665
+ /** The developer changed the file since the last sync (unknowable without a base). */
666
+ let localTextChanged = false;
667
+ if (fileExists && fileSource !== null && localFile !== null && originalSource !== null) {
668
+ // The text 3-way (A12): the code outside `$sid` elements. The
669
+ // document merge above already settled every element; here only the
670
+ // surrounding code is at stake. Remote wins when the developer left the
671
+ // file alone; both changed is a conflict, and P0 keeps the local file.
672
+ const remoteTextChanged = remoteSource !== null && baseSource !== null && remoteSource.text !== baseSource.text;
673
+ // Route markers are the CLI's own (placement rewrites them), never a code edit.
674
+ localTextChanged =
675
+ baseSource !== null &&
676
+ withoutRouteMarker(originalSource) !== withoutRouteMarker(baseSource.text);
677
+ let fromRemote = null;
678
+ /** Both sides changed the code and the hunks did not overlap (A13). */
679
+ let mergedCode = false;
680
+ let conflictDetail = null;
681
+ if (remoteTextChanged && remoteSource !== null && baseSource !== null) {
682
+ let theirs = remoteSource.text;
683
+ if (localTextChanged) {
684
+ // The text 3-way (A13): base = the text both sides started from,
685
+ // ours = the file as the developer left it (tagged, so its `$sid`s
686
+ // match the trees merged above), theirs = Creator's text now. The
687
+ // route marker is the CLI's own: merged without it, put back after.
688
+ const merged = merge3(withoutRouteMarker(baseSource.text), withoutRouteMarker(fileSource), withoutRouteMarker(remoteSource.text));
689
+ if (merged.ok) {
690
+ const marker = fileSource.split('\n').find((line) => ROUTE_MARKER.test(line));
691
+ theirs = marker === undefined ? merged.text : `${marker}\n${merged.text}`;
692
+ mergedCode = true;
693
+ }
694
+ else {
695
+ conflictDetail = `${merged.conflicts} change(s) touch the same lines`;
696
+ }
697
+ }
698
+ if (conflictDetail === null) {
699
+ fromRemote = renderFromSource({ ...remoteSource, text: theirs }, mergedFile, mapping.file, report, mergedStates);
700
+ if (fromRemote === null && mergedCode) {
701
+ conflictDetail = 'the merged code has no $sid root';
702
+ }
703
+ }
704
+ if (conflictDetail !== null) {
705
+ mergedCode = false;
706
+ fromRemote = renderFromSource(remoteSource, mergedFile, mapping.file, report, mergedStates);
707
+ }
708
+ }
709
+ if (fromRemote !== null) {
710
+ if (localTextChanged && !mergedCode) {
711
+ const deprecatedTo = relative(root, deprecateFile(filePath));
712
+ report.conflicts.push({
713
+ sid: mergedFile.sid,
714
+ reason: 'source-text',
715
+ detail: `the code around the elements changed both here and in Creator${conflictDetail === null ? '' : ` — ${conflictDetail}`}`,
716
+ file: mapping.file,
717
+ deprecatedTo,
718
+ });
719
+ report.files.push({
720
+ file: mapping.file,
721
+ action: 'deprecated',
722
+ detail: `code conflict — your version kept at ${deprecatedTo}; Creator's code applied`,
723
+ });
724
+ }
725
+ else {
726
+ report.files.push({
727
+ file: mapping.file,
728
+ action: 'updated',
729
+ detail: mergedCode ? 'code merged with Creator' : 'code edited in Creator',
730
+ });
731
+ }
732
+ mkdirSync(dirname(filePath), { recursive: true });
733
+ writeFileSync(filePath, fromRemote);
734
+ written = fromRemote;
450
735
  }
451
736
  else {
452
- report.files.push({ file: mapping.file, action: 'unchanged' });
737
+ const edits = treeToEdits(localFile, mergedFile);
738
+ // Import healing: patch-inserted elements must be importable, and files
739
+ // touched by an interrupted earlier sync may be missing imports already.
740
+ const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
741
+ if (edits.length > 0 || healed !== fileSource) {
742
+ const { source: patched, warnings } = patch(healed, edits, { states: mergedStates });
743
+ for (const w of warnings)
744
+ report.warnings.push({ ...w, file: mapping.file });
745
+ writeFileSync(filePath, patched);
746
+ report.files.push({ file: mapping.file, action: 'updated' });
747
+ written = patched;
748
+ }
749
+ else {
750
+ report.files.push({ file: mapping.file, action: 'unchanged' });
751
+ written = fileSource;
752
+ }
453
753
  }
454
754
  }
455
755
  else {
456
756
  mkdirSync(dirname(filePath), { recursive: true });
457
- writeFileSync(filePath, await emitPage({ name: remotePage.name, root: merged }, filePath));
757
+ written = await fileTextFor(mergedEntity, mapping, remoteSource, mergedFile, filePath);
758
+ writeFileSync(filePath, written);
458
759
  report.files.push({ file: mapping.file, action: fileExists ? 'regenerated' : 'created' });
459
760
  }
460
- // Push = local changes rebased onto remote.
461
- pushOps.push(...diffTree(remoteRoot, merged));
462
- mergedBase.pages.set(pageId, { ...remotePage, root: merged });
463
- mapping.name = remotePage.name;
464
- mapping.route = remotePage.route;
761
+ recordSource(id, remoteSource, written, localTextChanged);
762
+ // Push = local changes rebased onto remote. Moves into containers created
763
+ // by this push go in a later transaction, once those have real ids.
764
+ const staged = diffTreeStaged(remoteRoot, merged);
765
+ pushOps.push(...staged.ops);
766
+ deferredOps.push(...staged.deferred);
767
+ const statePatch = buildMergePatch({ state: remoteStates }, { state: mergedStates });
768
+ if (statePatch !== null) {
769
+ pushOps.push({ op: 'update', entity: kind.entity, id, patch: statePatch });
770
+ }
771
+ const next = { ...mergedEntity, root: merged };
772
+ if (Object.keys(mergedStates).length === 0)
773
+ delete next.state;
774
+ mergedOut.set(id, next);
775
+ mapping.name = remoteEntity.name;
465
776
  }
466
- // ---- Non-page entities: style, navigation, assets ----
467
- const styleResult = syncStyleFile(root, base.style, remote.style, report);
468
- pushOps.push(...styleResult.pushOps);
469
- mergedBase.style = styleResult.mergedStyle;
470
- const navResult = syncNavigation(root, config.routesDir ?? 'app/(mapples)', pagesMap, base.navigation, remote.navigation, report);
471
- pushOps.push(...navResult.pushOps);
472
- mergedBase.navigation = navResult.mergedNavigation;
473
- const assetsResult = await syncAssets(root, client, remote.assets, report);
474
- pushOps.push(...assetsResult.pushOps);
475
- mergedBase.assets = assetsResult.mergedAssets;
476
- return { pushOps, mergedBase, pagesMap };
777
+ }
778
+ /** A warning sink that lands each warning in the report stamped with its file. */
779
+ function warnInto(report, file) {
780
+ return (w) => {
781
+ report.warnings.push({ ...w, file });
782
+ };
783
+ }
784
+ /**
785
+ * A container's stored source (A12) brought up to date with `tree` — the
786
+ * FILE-language tree the file must show: its `$sid` elements are re-patched
787
+ * from the document (which is the truth for them), everything around them is
788
+ * the text as stored. Null when the text holds no `$sid` root, which is
789
+ * reported and regenerated rather than silently guessed.
790
+ */
791
+ function renderFromSource(source, tree, file, report, docStates) {
792
+ if (source === null)
793
+ return null;
794
+ const extracted = extract(source.text);
795
+ if (extracted.dom === null) {
796
+ report.notes.push(`${file}: the source stored in Creator has no $sid root — regenerated from the canvas instead`);
797
+ return null;
798
+ }
799
+ fillNonLiteralProps(extracted.dom, tree, extracted.warnings);
800
+ // What renders under a prop test is the text's to say: the document may
801
+ // predate it, and must not strip a guard or a prop-state style from code.
802
+ const adopted = adoptPropConditions(tree, extracted.dom, extracted.states, docStates);
803
+ const healed = upsertImports(source.text, importNeedsFor(tree));
804
+ const edits = treeToEdits(extracted.dom, adopted.tree);
805
+ if (edits.length === 0)
806
+ return healed;
807
+ const { source: patched, warnings } = patch(healed, edits, { states: adopted.states });
808
+ for (const w of warnings)
809
+ report.warnings.push({ ...w, file });
810
+ return patched;
811
+ }
812
+ /**
813
+ * Source ops for the containers the merge marked for upload: a create where
814
+ * the server has no Source yet, an update otherwise. `idMap` resolves
815
+ * containers created by the node push that just landed.
816
+ */
817
+ function sourceOpsFor(mergedBase, remote, uploads, idMap) {
818
+ const ops = [];
819
+ for (const containerId of uploads) {
820
+ const serverId = idMap[containerId] ?? containerId;
821
+ if (serverId.startsWith('c_'))
822
+ continue; // its page never landed (offline): next sync
823
+ const source = mergedBase.sources.get(serverId);
824
+ if (!source)
825
+ throw new Error(`internal: no merged source for ${serverId}`);
826
+ const remoteSource = remote.sources.get(serverId) ?? null;
827
+ if (remoteSource === null) {
828
+ ops.push({
829
+ op: 'create',
830
+ entity: 'Source',
831
+ cid: mintCid(),
832
+ data: { of: serverId, lang: source.lang, text: source.text },
833
+ });
834
+ }
835
+ else if (remoteSource.text !== source.text) {
836
+ // A13: hunks against the server's text at the push base, never the file.
837
+ ops.push({
838
+ op: 'update',
839
+ entity: 'Source',
840
+ id: remoteSource.id,
841
+ text: {
842
+ base: textHash(remoteSource.text),
843
+ hunks: makeHunks(remoteSource.text, source.text),
844
+ },
845
+ });
846
+ }
847
+ }
848
+ return ops;
477
849
  }
478
850
  /** NON_LITERAL_PROP contract: expression props keep their last-known base value. */
479
851
  function fillNonLiteralProps(local, baseRoot, warnings) {
@@ -501,13 +873,35 @@ function fillNonLiteralProps(local, baseRoot, warnings) {
501
873
  }
502
874
  }
503
875
  }
876
+ // ------------------------------------------------------------ client ids
877
+ /** Ids the server accepts as-is (RFC-0001 §5): a DB id, or a ULID-shaped `c_` placeholder. */
878
+ const SERVER_ID_RE = /^(nd|pg|cmp|rt|st|th|ty|sz|as|nav)_\w+$/;
879
+ const CLIENT_ID_RE = /^c_[0-9A-HJKMNP-TV-Z]{26}$/;
880
+ /**
881
+ * Replace every sid the server would reject with a freshly minted `c_` id,
882
+ * in place. Returns old -> new so the caller can rewrite the file the same
883
+ * way `rewriteIds` does after a push.
884
+ */
885
+ export function normalizeClientIds(root) {
886
+ const renamed = new Map();
887
+ const walk = (node) => {
888
+ if (!SERVER_ID_RE.test(node.sid) && !CLIENT_ID_RE.test(node.sid)) {
889
+ const fresh = mintCid();
890
+ renamed.set(node.sid, fresh);
891
+ node.sid = fresh;
892
+ }
893
+ node.children.forEach(walk);
894
+ };
895
+ walk(root);
896
+ return renamed;
897
+ }
504
898
  // ------------------------------------------------------------ idMap rewrite
505
899
  /** After a successful push, rewrite client-minted c_ sids to server nd_ ids in files and base. */
506
- function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
900
+ function rewriteIds(root, mergedBase, fileMaps, idMap, report) {
507
901
  const entries = Object.entries(idMap).filter(([cid]) => cid.startsWith('c_'));
508
902
  if (entries.length === 0)
509
903
  return;
510
- for (const mapping of Object.values(pagesMap)) {
904
+ for (const mapping of fileMaps.flatMap((m) => Object.values(m))) {
511
905
  const filePath = join(root, mapping.file);
512
906
  if (!existsSync(filePath))
513
907
  continue;
@@ -530,6 +924,19 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
530
924
  };
531
925
  for (const page of mergedBase.pages.values())
532
926
  renameTree(page.root);
927
+ for (const component of mergedBase.components.values())
928
+ renameTree(component.root);
929
+ // Source text mirrors the files: same rewrite, and a container minted by
930
+ // this push re-keys its entry under the server id.
931
+ const renamedSources = new Map();
932
+ for (const [containerId, source] of mergedBase.sources) {
933
+ let text = source.text;
934
+ for (const [cid, id] of entries)
935
+ text = text.split(`"${cid}"`).join(`"${id}"`);
936
+ const of = idMap[containerId] ?? containerId;
937
+ renamedSources.set(of, { ...source, id: sourceIdFor(of), of, text });
938
+ }
939
+ mergedBase.sources = renamedSources;
533
940
  if (mergedBase.assets) {
534
941
  mergedBase.assets = resolveAssetIds(mergedBase.assets, idMap);
535
942
  writeAssetMap(root, mergedBase.assets);
@@ -537,58 +944,95 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
537
944
  report.notes.push(`resolved ${entries.length} new node id(s) from the server`);
538
945
  }
539
946
  // ----------------------------------------------------------------- finalize
540
- function remoteAsBase(base, remote) {
947
+ function remoteAsBase(remote) {
541
948
  return {
542
- mergedBase: {
543
- pages: remote.pages,
544
- components: base.components,
545
- navigation: remote.navigation,
546
- style: remote.style,
547
- assets: remote.assets,
548
- },
549
- pagesMap: {},
949
+ pages: remote.pages,
950
+ components: remote.components,
951
+ navigation: remote.navigation,
952
+ style: remote.style,
953
+ assets: remote.assets,
954
+ sources: new Map([...remote.sources].map(([id, source]) => [id, { ...source, onServer: true }])),
550
955
  };
551
956
  }
552
- function finalize(root, baseStore, mergedBase, pagesMap, head, seq) {
957
+ function finalize(root, baseStore, mergedBase, pagesMap, componentsMap, head, seq) {
553
958
  baseStore.writeAll(mergedBase);
959
+ baseStore.writeStamp(head);
554
960
  writePagesMap(root, pagesMap);
961
+ writeComponentsMap(root, componentsMap);
555
962
  writeHead(root, { head, seq });
556
963
  }
557
- async function commitLoop(client, base, ops, txId, report) {
558
- let cursor = base;
559
- for (let attempt = 0; attempt < MAX_REBASE_RETRIES; attempt++) {
560
- try {
561
- const result = await commitWithTransportRetry(client, buildEnvelope(client, { base: cursor, ops, txId }));
562
- return { head: result.head, seq: result.seq };
563
- }
564
- catch (err) {
565
- if (err instanceof HeadMismatchError) {
964
+ function newProgress(head, seq) {
965
+ return { head, seq, idMap: {}, committedOps: 0 };
966
+ }
967
+ /**
968
+ * POST `txs` (planned by planTransactions, RFC-0001 §5.1/§5.2) as sequential
969
+ * transactions. cids are transaction-scoped (§5.2, invariant 5): a later
970
+ * transaction may reference the nodes an earlier one created only by their
971
+ * server ids, so every transaction is rewritten through the idMaps collected
972
+ * so far before it is sent — otherwise the server answers CID_UNRESOLVED.
973
+ *
974
+ * `progress` advances after each committed transaction; when this throws, it
975
+ * tells the caller exactly what already landed (`committedOps` counts against
976
+ * `txs.flat()`). The first transaction reuses `firstTxId` (the caller's replay
977
+ * handle, RFC-0001 §7.3); later ones mint their own. `rebaseInPlace` retries a
978
+ * 409 on the same transaction from the server's head (the outbox drain has
979
+ * nothing local to re-merge); otherwise the 409 propagates so the caller can
980
+ * re-merge against the advanced remote. Every op the server dropped and every
981
+ * warning it raised lands in the report.
982
+ */
983
+ async function pushChunked(client, txs, firstTxId, progress, opts) {
984
+ for (const [i, tx] of txs.entries()) {
985
+ const chunk = resolveCids(tx, progress.idMap);
986
+ const txId = i === 0 ? firstTxId : mintTxId();
987
+ let cursor = progress.head;
988
+ let result;
989
+ for (let attempt = 0; result === undefined; attempt++) {
990
+ try {
991
+ result = await commitWithTransportRetry(client, buildEnvelope(client, { base: cursor, ops: chunk, txId }));
992
+ }
993
+ catch (err) {
994
+ if (!opts.rebaseInPlace || !(err instanceof HeadMismatchError))
995
+ throw err;
996
+ if (attempt + 1 >= MAX_REBASE_RETRIES) {
997
+ throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
998
+ }
566
999
  cursor = err.head;
567
- report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
568
- continue;
1000
+ opts.report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
569
1001
  }
570
- throw err;
571
1002
  }
1003
+ progress.head = result.head;
1004
+ progress.seq = result.seq;
1005
+ progress.idMap = { ...progress.idMap, ...result.idMap };
1006
+ progress.committedOps += chunk.length;
1007
+ for (const d of result.dropped ?? []) {
1008
+ const op = chunk[d.index];
1009
+ if (!op) {
1010
+ throw new Error(`server dropped op ${d.index} of a ${chunk.length}-op transaction — server/CLI contract drift`);
1011
+ }
1012
+ opts.report.dropped.push({ ...d, op });
1013
+ }
1014
+ for (const w of result.warnings ?? []) {
1015
+ if (!opts.report.serverWarnings.includes(w))
1016
+ opts.report.serverWarnings.push(w);
1017
+ }
1018
+ opts.onChunk?.(result.idMap);
572
1019
  }
573
- throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
574
1020
  }
575
1021
  function parkOffline(root, base, outbox, report) {
576
1022
  // Offline before we could pull: compute local ops vs base and park them.
577
- const pagesMap = readPagesMap(root);
578
1023
  const ops = [];
579
- for (const [pageId, mapping] of Object.entries(pagesMap)) {
580
- const basePage = base.pages.get(pageId);
581
- if (!basePage)
582
- continue;
1024
+ const linker = instanceLinker(readComponentsMap(root), base.components);
1025
+ for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
583
1026
  const filePath = join(root, mapping.file);
584
1027
  if (!existsSync(filePath))
585
1028
  continue;
586
1029
  const extracted = extract(readFileSync(filePath, 'utf8'));
587
1030
  for (const w of extracted.warnings)
588
1031
  report.warnings.push({ ...w, file: mapping.file });
589
- if (extracted.dom && basePage) {
590
- fillNonLiteralProps(extracted.dom, basePage.root, extracted.warnings);
591
- ops.push(...diffTree(basePage.root, extracted.dom));
1032
+ if (extracted.dom) {
1033
+ linker.link(extracted.dom, mapping.file, warnInto(report, mapping.file));
1034
+ fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
1035
+ ops.push(...diffTree(baseEntity.root, extracted.dom));
592
1036
  }
593
1037
  }
594
1038
  if (ops.length > 0) {
@@ -598,20 +1042,36 @@ function parkOffline(root, base, outbox, report) {
598
1042
  report.pushedOps = ops.length;
599
1043
  return report;
600
1044
  }
1045
+ /** Every mapped page and component that exists in the base, with its mapping. */
1046
+ function mappedEntities(root, base) {
1047
+ const out = [];
1048
+ for (const [id, mapping] of Object.entries(readPagesMap(root))) {
1049
+ const page = base.pages.get(id);
1050
+ if (page)
1051
+ out.push([id, mapping, page]);
1052
+ }
1053
+ for (const [id, mapping] of Object.entries(readComponentsMap(root))) {
1054
+ const component = base.components.get(id);
1055
+ if (component)
1056
+ out.push([id, mapping, component]);
1057
+ }
1058
+ return out;
1059
+ }
601
1060
  export function verifyNoLocalDrift(root) {
602
1061
  // Used by doctor (Phase 8): re-extract every mapped file and compare to base.
603
1062
  const base = new BaseStore(root).readAll();
604
- const pagesMap = readPagesMap(root);
1063
+ const linker = instanceLinker(readComponentsMap(root), base.components);
605
1064
  const results = [];
606
- for (const [pageId, mapping] of Object.entries(pagesMap)) {
607
- const basePage = base.pages.get(pageId);
1065
+ for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
608
1066
  const filePath = join(root, mapping.file);
609
- if (!basePage || !existsSync(filePath))
1067
+ if (!existsSync(filePath))
610
1068
  continue;
611
1069
  const extracted = extract(readFileSync(filePath, 'utf8'));
612
- if (extracted.dom && basePage)
613
- fillNonLiteralProps(extracted.dom, basePage.root, extracted.warnings);
614
- results.push({ file: mapping.file, drifted: !deepEqual(extracted.dom, basePage.root) });
1070
+ if (extracted.dom) {
1071
+ linker.link(extracted.dom, mapping.file, () => undefined);
1072
+ fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
1073
+ }
1074
+ results.push({ file: mapping.file, drifted: !deepEqual(extracted.dom, baseEntity.root) });
615
1075
  }
616
1076
  return results;
617
1077
  }