mapples 0.2.0-beta.1 → 0.2.0-beta.4

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 (93) hide show
  1. package/README.md +70 -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/client.d.ts +9 -1
  6. package/dist/api/client.js +21 -2
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/api/commit.d.ts +9 -0
  9. package/dist/api/commit.js +32 -2
  10. package/dist/api/commit.js.map +1 -1
  11. package/dist/api/errors.js +8 -0
  12. package/dist/api/errors.js.map +1 -1
  13. package/dist/api/objects.d.ts +24 -5
  14. package/dist/api/objects.js +174 -10
  15. package/dist/api/objects.js.map +1 -1
  16. package/dist/api/poll.d.ts +5 -3
  17. package/dist/api/poll.js +17 -6
  18. package/dist/api/poll.js.map +1 -1
  19. package/dist/assets/assets.d.ts +9 -0
  20. package/dist/assets/assets.js +33 -4
  21. package/dist/assets/assets.js.map +1 -1
  22. package/dist/codegen/emit.d.ts +4 -7
  23. package/dist/codegen/emit.js +10 -29
  24. package/dist/codegen/emit.js.map +1 -1
  25. package/dist/codegen/imports.d.ts +1 -15
  26. package/dist/codegen/imports.js +2 -78
  27. package/dist/codegen/imports.js.map +1 -1
  28. package/dist/codegen/instances.d.ts +5 -0
  29. package/dist/codegen/instances.js +30 -0
  30. package/dist/codegen/instances.js.map +1 -0
  31. package/dist/codegen/styles.d.ts +1 -0
  32. package/dist/codegen/styles.js +3 -0
  33. package/dist/codegen/styles.js.map +1 -0
  34. package/dist/commands/impl/createImpl.js +8 -4
  35. package/dist/commands/impl/createImpl.js.map +1 -1
  36. package/dist/commands/impl/initImpl.js +5 -4
  37. package/dist/commands/impl/initImpl.js.map +1 -1
  38. package/dist/commands/impl/syncImpl.js +20 -2
  39. package/dist/commands/impl/syncImpl.js.map +1 -1
  40. package/dist/merge/applyOps.d.ts +1 -1
  41. package/dist/merge/applyOps.js +33 -2
  42. package/dist/merge/applyOps.js.map +1 -1
  43. package/dist/merge/diff.d.ts +1 -1
  44. package/dist/merge/diff.js +30 -17
  45. package/dist/merge/diff.js.map +1 -1
  46. package/dist/merge/threeWay.d.ts +2 -2
  47. package/dist/merge/threeWay.js +38 -6
  48. package/dist/merge/threeWay.js.map +1 -1
  49. package/dist/merge/toEdits.d.ts +1 -3
  50. package/dist/merge/toEdits.js +3 -91
  51. package/dist/merge/toEdits.js.map +1 -1
  52. package/dist/nav/navigatorTree.d.ts +78 -0
  53. package/dist/nav/navigatorTree.js +410 -0
  54. package/dist/nav/navigatorTree.js.map +1 -0
  55. package/dist/nav/placement.d.ts +53 -0
  56. package/dist/nav/placement.js +260 -0
  57. package/dist/nav/placement.js.map +1 -0
  58. package/dist/nav/routeTree.d.ts +8 -2
  59. package/dist/nav/routeTree.js +79 -151
  60. package/dist/nav/routeTree.js.map +1 -1
  61. package/dist/nav/screenOptions.d.ts +15 -0
  62. package/dist/nav/screenOptions.js +213 -0
  63. package/dist/nav/screenOptions.js.map +1 -0
  64. package/dist/project/baseStore.d.ts +19 -1
  65. package/dist/project/baseStore.js +28 -0
  66. package/dist/project/baseStore.js.map +1 -1
  67. package/dist/project/componentsMap.d.ts +7 -0
  68. package/dist/project/componentsMap.js +21 -0
  69. package/dist/project/componentsMap.js.map +1 -0
  70. package/dist/project/config.d.ts +12 -3
  71. package/dist/project/config.js +11 -2
  72. package/dist/project/config.js.map +1 -1
  73. package/dist/project/env.d.ts +10 -3
  74. package/dist/project/env.js +23 -5
  75. package/dist/project/env.js.map +1 -1
  76. package/dist/project/pagesMap.d.ts +2 -0
  77. package/dist/project/pagesMap.js.map +1 -1
  78. package/dist/services/adoptService.d.ts +3 -0
  79. package/dist/services/adoptService.js +346 -38
  80. package/dist/services/adoptService.js.map +1 -1
  81. package/dist/services/clientFactory.js +4 -2
  82. package/dist/services/clientFactory.js.map +1 -1
  83. package/dist/services/doctorService.js +77 -66
  84. package/dist/services/doctorService.js.map +1 -1
  85. package/dist/services/syncEngine.d.ts +7 -0
  86. package/dist/services/syncEngine.js +657 -300
  87. package/dist/services/syncEngine.js.map +1 -1
  88. package/dist/types.d.ts +40 -2
  89. package/dist/types.js +25 -0
  90. package/dist/types.js.map +1 -1
  91. package/package.json +3 -2
  92. package/templates/app/_layout.tsx +3 -0
  93. package/templates/mapples/sid.d.ts +6 -0
@@ -1,32 +1,58 @@
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 { codemodAdopt, extract, patch, upsertImports } from '@mapples/sid-ast';
7
13
  import { isNetworkError } from '../api/client.js';
8
- import { buildEnvelope, commitWithTransportRetry, mintTxId } from '../api/commit.js';
14
+ import { buildEnvelope, commitWithTransportRetry, mintCid, mintTxId, resolveCids, } from '../api/commit.js';
9
15
  import { HeadMismatchError, JpatchError } from '../api/errors.js';
10
16
  import { pullDelta, SnapshotRequired } from '../api/poll.js';
11
17
  import { fetchProjectState, } from '../api/objects.js';
12
- import { emitPage, mappletName } from '../codegen/emit.js';
13
- import { applyNodeOps } from '../merge/applyOps.js';
18
+ import { PRIMITIVE_MAPPING } from '../adopt/mappingTable.js';
19
+ import { emitComponent, emitPage, withoutRouteMarker } from '../codegen/emit.js';
20
+ import { importNeedsFor } from '../codegen/imports.js';
21
+ import { instanceLinker } from '../codegen/instances.js';
14
22
  import { deprecateFile } from '../merge/deprecate.js';
15
23
  import { diffTree } from '../merge/diff.js';
16
- import { applyMergePatch, deepEqual } from '../merge/mergePatch.js';
24
+ import { deepEqual } from '../merge/mergePatch.js';
17
25
  import { threeWayMerge } from '../merge/threeWay.js';
18
26
  import { treeToEdits } from '../merge/toEdits.js';
19
27
  import { BaseStore } from '../project/baseStore.js';
20
- import { readConfig } from '../project/config.js';
28
+ import { DEFAULT_COMPONENTS_DIR, DEFAULT_ROUTES_DIR, readConfig, } from '../project/config.js';
29
+ import { readComponentsMap, writeComponentsMap, } from '../project/componentsMap.js';
21
30
  import { readHead, writeHead } from '../project/head.js';
22
31
  import { Outbox } from '../project/outbox.js';
23
- import { readPagesMap, writePagesMap } from '../project/pagesMap.js';
32
+ import { readPagesMap, writePagesMap, } from '../project/pagesMap.js';
24
33
  import { resolveAssetIds, syncAssets, writeAssetMap } from '../assets/assets.js';
34
+ import { mintEntityFile } from '../nav/placement.js';
25
35
  import { syncNavigation } from '../nav/routeTree.js';
26
36
  import { syncStyleFile } from '../style/theme.js';
27
37
  import { emptyReport } from '../ui/report.js';
38
+ import { sourceIdFor, } from '../types.js';
28
39
  const MAX_REBASE_RETRIES = 5;
29
40
  const MAX_OPS_PER_TX = 200;
41
+ /** Source ops carry whole files (≤256 KiB each): fewer per transaction than node ops. */
42
+ const SOURCE_OPS_PER_TX = 10;
43
+ // buildRemoteModel needs the project root to read the stored head; carried as
44
+ // a non-enumerable tag so BaseSnapshot's shape stays unchanged for callers.
45
+ const ROOT_TAG = Symbol('projectRoot');
46
+ function baseWithRoot(base, root) {
47
+ Object.defineProperty(base, ROOT_TAG, { value: root, configurable: true, enumerable: false });
48
+ return base;
49
+ }
50
+ function rootOf(base) {
51
+ const root = base[ROOT_TAG];
52
+ if (!root)
53
+ throw new Error('internal: base snapshot missing project root tag');
54
+ return root;
55
+ }
30
56
  export async function runSyncEngine({ root, client }) {
31
57
  const report = emptyReport();
32
58
  const config = readConfig(root);
@@ -35,19 +61,32 @@ export async function runSyncEngine({ root, client }) {
35
61
  const head = readHead(root);
36
62
  // ---- Cold bootstrap ----
37
63
  if (!head || !baseStore.exists()) {
38
- return bootstrap(root, client, config.appDir, report);
64
+ return bootstrap(root, client, config, report);
39
65
  }
40
66
  // ---- Drain outbox ----
41
67
  let currentHead = head.head;
68
+ let currentSeq = head.seq;
42
69
  for (const entry of outbox.list()) {
70
+ const progress = newProgress(currentHead, currentSeq);
43
71
  try {
44
- const result = await commitLoop(client, currentHead, entry.ops, entry.txId, report);
45
- currentHead = result.head;
72
+ await pushChunked(client, entry.ops, entry.txId, progress, { rebaseInPlace: true, report });
73
+ currentHead = progress.head;
74
+ currentSeq = progress.seq;
46
75
  outbox.clear(entry.txId);
47
76
  report.notes.push(`drained outbox tx ${entry.txId} (${entry.ops.length} ops)`);
48
77
  }
49
78
  catch (err) {
50
79
  if (isNetworkError(err)) {
80
+ if (progress.committedOps > 0) {
81
+ // Part of a split entry landed. Keep only what is left, with the
82
+ // committed cids resolved, under a fresh txId — the old one is spent.
83
+ outbox.clear(entry.txId);
84
+ outbox.park({
85
+ txId: mintTxId(),
86
+ ops: resolveCids(entry.ops.slice(progress.committedOps), progress.idMap),
87
+ parkedAt: entry.parkedAt,
88
+ });
89
+ }
51
90
  report.offline = true;
52
91
  report.notes.push('still offline — outbox kept');
53
92
  return report;
@@ -64,7 +103,7 @@ export async function runSyncEngine({ root, client }) {
64
103
  const base = baseStore.readAll();
65
104
  let remote;
66
105
  try {
67
- remote = await buildRemoteModel(client, base, currentHead, report);
106
+ remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
68
107
  }
69
108
  catch (err) {
70
109
  if (isNetworkError(err)) {
@@ -73,12 +112,15 @@ export async function runSyncEngine({ root, client }) {
73
112
  throw err;
74
113
  }
75
114
  // ---- Merge + write + push (with 409 rebase loop) ----
76
- const txId = mintTxId();
115
+ let txId = mintTxId();
77
116
  let attempt = 0;
78
117
  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);
118
+ const { pushOps, mergedBase, pagesMap, componentsMap, sourceUploads } = await mergeAndWrite(root, client, config, base, remote, report);
119
+ // Source text goes out AFTER the node ops (its `$sid`s must be server
120
+ // ids, which the node push mints), as its own transaction(s).
121
+ let sourceOps = [];
122
+ if (pushOps.length === 0 && sourceUploads.size === 0) {
123
+ finalize(root, baseStore, mergedBase, pagesMap, componentsMap, remote.head, remote.seq);
82
124
  report.head = remote.head;
83
125
  return report;
84
126
  }
@@ -86,28 +128,45 @@ export async function runSyncEngine({ root, client }) {
86
128
  // Split into sequential transactions (RFC-0001 §5.1 TX_TOO_LARGE discipline).
87
129
  report.notes.push(`push split into ${Math.ceil(pushOps.length / MAX_OPS_PER_TX)} transactions`);
88
130
  }
131
+ const progress = newProgress(remote.head, remote.seq);
89
132
  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}`);
133
+ if (pushOps.length > 0) {
134
+ await pushChunked(client, pushOps, txId, progress, {
135
+ rebaseInPlace: false,
136
+ report,
137
+ // Rewrite as each chunk lands, so files and base never hold a cid the
138
+ // server has already resolved: a re-merge or a park after a later
139
+ // chunk fails then sees the committed nodes by their real ids.
140
+ onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
141
+ });
142
+ report.pushedOps += pushOps.length;
143
+ }
144
+ sourceOps = sourceOpsFor(mergedBase, remote, sourceUploads, progress.idMap);
145
+ try {
146
+ for (let i = 0; i < sourceOps.length; i += SOURCE_OPS_PER_TX) {
147
+ await pushChunked(client, sourceOps.slice(i, i + SOURCE_OPS_PER_TX), pushOps.length === 0 && i === 0 ? txId : mintTxId(), progress, { rebaseInPlace: false, report });
148
+ }
149
+ if (sourceOps.length > 0) {
150
+ report.pushedOps += sourceOps.length;
151
+ report.notes.push(`uploaded the source text of ${sourceOps.length} file(s) to Creator`);
152
+ }
153
+ }
154
+ catch (err) {
155
+ if (!(err instanceof JpatchError && err.code === 'UNKNOWN_ENTITY'))
156
+ throw err;
157
+ // A Creator server from before A12 rejects the entity. The node ops
158
+ // already landed; the code stays local and is offered again once the
159
+ // server stores source text — never silently.
160
+ for (const id of sourceUploads) {
161
+ const entry = mergedBase.sources.get(progress.idMap[id] ?? id);
162
+ if (entry)
163
+ entry.onServer = false;
105
164
  }
165
+ sourceOps = [];
166
+ report.notes.push('this Creator server does not store source text yet — the code around your elements stays local until it does');
106
167
  }
107
- report.pushedOps += pushOps.length;
108
- rewriteIds(root, mergedBase, pagesMap, idMap, report);
109
- finalize(root, baseStore, mergedBase, pagesMap, resultHead, resultSeq);
110
- report.head = resultHead;
168
+ finalize(root, baseStore, mergedBase, pagesMap, componentsMap, progress.head, progress.seq);
169
+ report.head = progress.head;
111
170
  return report;
112
171
  }
113
172
  catch (err) {
@@ -116,18 +175,31 @@ export async function runSyncEngine({ root, client }) {
116
175
  if (attempt >= MAX_REBASE_RETRIES) {
117
176
  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
177
  }
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
178
+ if (progress.committedOps > 0) {
179
+ // Earlier chunks landed under `txId`; replaying it would hand back
180
+ // their result instead of committing the re-merged ops.
181
+ report.pushedOps += progress.committedOps;
182
+ txId = mintTxId();
183
+ }
184
+ report.notes.push(`rebase ${attempt}: HEAD moved to ${err.head} — refetching state`);
185
+ remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
186
+ continue; // re-merge against the advanced remote, retry the same txId unless it is spent
122
187
  }
123
188
  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;
189
+ // Files already show the merged view; park what did not land (with
190
+ // the committed chunks' cids resolved) and adopt the remote head so
191
+ // the drain pushes exactly these ops next time.
192
+ // Source ops not yet computed (offline during the node push) are
193
+ // simply recomputed next time: the files still differ from the base.
194
+ const allOps = [...pushOps, ...sourceOps];
195
+ outbox.park({
196
+ txId: progress.committedOps > 0 ? mintTxId() : txId,
197
+ ops: resolveCids(allOps.slice(progress.committedOps), progress.idMap),
198
+ parkedAt: new Date().toISOString(),
199
+ });
200
+ report.pushedOps = allOps.length;
128
201
  report.offline = true;
129
- const { mergedBase: remoteOnlyBase, pagesMap } = remoteAsBase(base, remote);
130
- finalize(root, baseStore, remoteOnlyBase, { ...readPagesMap(root), ...pagesMap }, remote.head, remote.seq);
202
+ finalize(root, baseStore, remoteAsBase(remote), readPagesMap(root), readComponentsMap(root), remote.head, remote.seq);
131
203
  return report;
132
204
  }
133
205
  throw err;
@@ -135,40 +207,71 @@ export async function runSyncEngine({ root, client }) {
135
207
  }
136
208
  }
137
209
  // ---------------------------------------------------------------- bootstrap
138
- async function bootstrap(root, client, appDir, report) {
210
+ async function bootstrap(root, client, config, report) {
139
211
  const boot = await client.bootstrap();
140
212
  const state = await fetchProjectState(client, boot);
141
213
  const baseStore = new BaseStore(root);
142
214
  const pagesMap = readPagesMap(root);
143
- const usedFiles = new Set(Object.values(pagesMap).map((m) => m.file));
215
+ // Placement first: every page gets its file position from the navigation.
216
+ const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, state.pages, pagesMap, state.navigation, state.navigation, report);
217
+ const componentsMap = readComponentsMap(root);
218
+ placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, state.components, componentsMap);
219
+ const linker = instanceLinker(componentsMap, state.components);
220
+ // A container with stored source text (A12) materializes from that text —
221
+ // hooks and handlers written in the Creator's editor included — patched to
222
+ // its current tree; the rest is generated.
223
+ const targets = [];
144
224
  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);
225
+ const mapping = pagesMap[page.id];
226
+ if (!mapping)
227
+ throw new Error(`internal: page ${page.id} was not placed`);
228
+ const tree = linker.unlink(page.root, mapping.file, warnInto(report, mapping.file));
229
+ targets.push({
230
+ id: page.id,
231
+ file: mapping.file,
232
+ emit: async () => renderFromSource(state.sources.get(page.id) ?? null, tree, mapping.file, report) ??
233
+ emitPage(emitInput(page.name, tree, mapping), join(root, mapping.file)),
234
+ });
235
+ }
236
+ for (const component of state.components.values()) {
237
+ const mapping = componentsMap[component.id];
238
+ const tree = linker.unlink(component.root, mapping.file, warnInto(report, mapping.file));
239
+ targets.push({
240
+ id: component.id,
241
+ file: mapping.file,
242
+ emit: async () => renderFromSource(state.sources.get(component.id) ?? null, tree, mapping.file, report) ??
243
+ emitComponent({ name: component.name, root: tree }, join(root, mapping.file)),
244
+ });
245
+ }
246
+ const baseSources = new Map();
247
+ for (const target of targets) {
248
+ const filePath = join(root, target.file);
156
249
  if (existsSync(filePath)) {
157
250
  // P0: never overwrite. The file's content will be merged on the next sync.
158
251
  report.files.push({
159
- file: mapping.file,
252
+ file: target.file,
160
253
  action: 'unchanged',
161
254
  detail: 'exists — left untouched',
162
255
  });
163
- continue;
164
256
  }
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' });
257
+ else {
258
+ mkdirSync(dirname(filePath), { recursive: true });
259
+ writeFileSync(filePath, await target.emit());
260
+ report.files.push({ file: target.file, action: 'created' });
261
+ }
262
+ // The text 3-way's base: the server's text where it has one, else the
263
+ // file as it stands (an untouched pre-existing file included — its own
264
+ // code then reads as a local change and uploads on the next sync).
265
+ const remoteSource = state.sources.get(target.id) ?? null;
266
+ baseSources.set(target.id, {
267
+ id: remoteSource?.id ?? sourceIdFor(target.id),
268
+ of: target.id,
269
+ lang: remoteSource?.lang ?? 'tsx',
270
+ text: remoteSource?.text ?? readFileSync(filePath, 'utf8'),
271
+ onServer: remoteSource !== null,
272
+ });
168
273
  }
169
- const config = readConfig(root);
170
274
  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
275
  const assetsResult = await syncAssets(root, client, state.assets, report);
173
276
  if (assetsResult.pushOps.length > 0) {
174
277
  report.notes.push('local assets found — run `mapples sync` again to upload them');
@@ -179,211 +282,274 @@ async function bootstrap(root, client, appDir, report) {
179
282
  navigation: navResult.mergedNavigation,
180
283
  style: styleResult.mergedStyle,
181
284
  assets: state.assets,
285
+ sources: baseSources,
182
286
  });
287
+ baseStore.writeStamp(state.head);
183
288
  writePagesMap(root, pagesMap);
289
+ writeComponentsMap(root, componentsMap);
184
290
  writeHead(root, { head: state.head, seq: state.seq });
185
291
  report.head = state.head;
186
- report.notes.push(`bootstrapped ${state.pages.size} page(s) @ ${state.head}`);
292
+ report.notes.push(`bootstrapped ${state.pages.size} page(s), ${state.components.size} component(s) @ ${state.head}`);
187
293
  return report;
188
294
  }
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;
295
+ /** Every Creator component gets a file under componentsDir; existing mappings are kept. */
296
+ function placeComponents(componentsDir, components, componentsMap) {
297
+ const used = new Set(Object.values(componentsMap).map((m) => m.file));
298
+ for (const [id, component] of components) {
299
+ if (componentsMap[id])
300
+ continue;
301
+ const file = mintEntityFile(componentsDir, component.name, used);
302
+ used.add(file);
303
+ componentsMap[id] = { file, name: component.name };
196
304
  }
197
- return file;
305
+ }
306
+ /** The route marker for a routed page's (re)generated file. */
307
+ function emitInput(name, root, mapping) {
308
+ return mapping.routeId ? { name, root, route: { id: mapping.routeId, name } } : { name, root };
198
309
  }
199
310
  // ------------------------------------------------------------- remote model
200
- async function buildRemoteModel(client, base, sinceHead, report) {
311
+ async function buildRemoteModel(client, base, sinceSeq, report) {
312
+ // The delta is used only to detect change and report op counts. The remote
313
+ // MODEL always comes from a full snapshot fetch: the real backend currently
314
+ // moves HEAD through legacy-write mirroring without emitting the matching
315
+ // ops (navigation saves, page creation — RFC-0001 invariant 10 violation),
316
+ // so op-replay would silently reconstruct a stale tree. Revisit when the
317
+ // backend guarantees every HEAD move carries its ops.
318
+ let head;
201
319
  try {
202
- const delta = await pullDelta(client, sinceHead);
320
+ const delta = await pullDelta(client, sinceSeq);
203
321
  report.pulledOps = delta.ops.length;
204
- const model = {
322
+ head = delta.head;
323
+ }
324
+ catch (err) {
325
+ if (!(err instanceof SnapshotRequired))
326
+ throw err;
327
+ report.notes.push('too far behind — cold re-bootstrap of remote state');
328
+ head = '';
329
+ }
330
+ const storedHead = readHead(rootOf(base)) ?? null;
331
+ const stamped = new BaseStore(rootOf(base)).readStamp();
332
+ if (head !== '' &&
333
+ storedHead &&
334
+ head === storedHead.head &&
335
+ stamped === storedHead.head &&
336
+ report.pulledOps === 0) {
337
+ // Nothing changed server-side and the base was written at this very
338
+ // head: the base IS the remote state.
339
+ return {
205
340
  pages: structuredClone(base.pages),
341
+ components: structuredClone(base.components),
206
342
  navigation: structuredClone(base.navigation),
207
343
  style: structuredClone(base.style),
208
344
  assets: structuredClone(base.assets),
209
- head: delta.head,
210
- seq: delta.seq,
345
+ sources: new Map([...base.sources]
346
+ .filter(([, source]) => source.onServer)
347
+ .map(([id, { onServer: _onServer, ...source }]) => [id, source])),
348
+ head: storedHead.head,
349
+ seq: storedHead.seq,
211
350
  };
212
- applyDeltaToRemote(model, delta.ops, delta.head, delta.seq, base, report);
213
- return model;
214
- }
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
351
  }
352
+ const boot = await client.bootstrap();
353
+ const state = await fetchProjectState(client, boot);
354
+ return {
355
+ pages: state.pages,
356
+ components: state.components,
357
+ navigation: state.navigation,
358
+ style: state.style,
359
+ assets: state.assets,
360
+ sources: state.sources,
361
+ head: state.head,
362
+ seq: state.seq,
363
+ };
231
364
  }
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;
333
- }
365
+ const PAGE_KIND = { label: 'page', entity: 'Page' };
366
+ const COMPONENT_KIND = { label: 'component', entity: 'Component' };
334
367
  async function mergeAndWrite(root, client, config, base, remote, report) {
335
- const appDir = config.appDir;
336
368
  const pagesMap = readPagesMap(root);
337
- const usedFiles = new Set(Object.values(pagesMap).map((m) => m.file));
369
+ const componentsMap = readComponentsMap(root);
338
370
  const pushOps = [];
371
+ const sourceUploads = new Set();
339
372
  const mergedBase = {
340
373
  pages: new Map(),
341
- components: base.components,
374
+ components: new Map(),
342
375
  navigation: remote.navigation,
343
376
  style: remote.style,
344
377
  assets: remote.assets,
378
+ sources: new Map(),
345
379
  };
346
380
  // Reset per-attempt report fields (the rebase loop re-runs this).
347
381
  report.files = [];
348
382
  report.conflicts = [];
349
383
  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];
384
+ // ---- Navigation first: it decides where every page file lives ----
385
+ const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, remote.pages, pagesMap, base.navigation, remote.navigation, report);
386
+ pushOps.push(...navResult.pushOps);
387
+ mergedBase.navigation = navResult.mergedNavigation;
388
+ placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, remote.components, componentsMap);
389
+ // Component uuids come from the remote model; a page's instances link to them.
390
+ const linker = instanceLinker(componentsMap, remote.components);
391
+ await mergeFiles({
392
+ root,
393
+ kind: PAGE_KIND,
394
+ base: base.pages,
395
+ remote: remote.pages,
396
+ mapping: pagesMap,
397
+ linker,
398
+ emit: (entity, mapping, tree, filePath) => emitPage(emitInput(entity.name, tree, mapping), filePath),
399
+ mergedOut: mergedBase.pages,
400
+ baseSources: base.sources,
401
+ remoteSources: remote.sources,
402
+ sourcesOut: mergedBase.sources,
403
+ sourceUploads,
404
+ pushOps,
405
+ report,
406
+ });
407
+ await mergeFiles({
408
+ root,
409
+ kind: COMPONENT_KIND,
410
+ base: base.components,
411
+ remote: remote.components,
412
+ mapping: componentsMap,
413
+ linker,
414
+ emit: (entity, _mapping, tree, filePath) => emitComponent({ name: entity.name, root: tree }, filePath),
415
+ mergedOut: mergedBase.components,
416
+ baseSources: base.sources,
417
+ remoteSources: remote.sources,
418
+ sourcesOut: mergedBase.sources,
419
+ sourceUploads,
420
+ pushOps,
421
+ report,
422
+ });
423
+ // ---- Non-page entities: style, assets ----
424
+ const styleResult = syncStyleFile(root, base.style, remote.style, report);
425
+ pushOps.push(...styleResult.pushOps);
426
+ mergedBase.style = styleResult.mergedStyle;
427
+ const assetsResult = await syncAssets(root, client, remote.assets, report);
428
+ pushOps.push(...assetsResult.pushOps);
429
+ mergedBase.assets = assetsResult.mergedAssets;
430
+ return { pushOps, mergedBase, pagesMap, componentsMap, sourceUploads };
431
+ }
432
+ /**
433
+ * The 3-way file merge for one entity kind: extract the file, merge against
434
+ * base and remote, write the merged view (patch in place, or regenerate),
435
+ * deprecate on conflict, and collect the ops that push local changes.
436
+ */
437
+ async function mergeFiles(args) {
438
+ const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, baseSources, remoteSources, sourcesOut, sourceUploads, pushOps, report, } = args;
439
+ /**
440
+ * The file's text after this sync becomes the next base. It goes to the
441
+ * server when a stored text exists and differs from the file (the file
442
+ * always wins: the merge above already folded the Creator's side in), or
443
+ * when the developer changed the code of a file the server has no text for.
444
+ */
445
+ const recordSource = (id, remoteSource, text, codeChanged) => {
446
+ const upload = remoteSource !== null ? remoteSource.text !== text : codeChanged;
447
+ if (upload)
448
+ sourceUploads.add(id);
449
+ sourcesOut.set(id, {
450
+ id: remoteSource?.id ?? sourceIdFor(id),
451
+ of: id,
452
+ lang: remoteSource?.lang ?? 'tsx',
453
+ text,
454
+ onServer: remoteSource !== null || upload,
455
+ });
456
+ };
457
+ /** A container's file text: its stored source patched to `tree`, else generated. */
458
+ const fileTextFor = async (entity, mapping, remoteSource, tree, filePath) => renderFromSource(remoteSource, tree, mapping.file, report) ??
459
+ (await emit(entity, mapping, tree, filePath));
460
+ const ids = new Set([...base.keys(), ...remote.keys()]);
461
+ for (const id of ids) {
462
+ const baseEntity = base.get(id) ?? null;
463
+ const remoteEntity = remote.get(id) ?? null;
464
+ const mapping = mappings[id];
355
465
  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);
466
+ // Known to the base only and never written to disk: nothing to reconcile.
467
+ if (remoteEntity)
468
+ throw new Error(`internal: ${kind.label} ${id} was not placed`);
469
+ continue;
364
470
  }
365
471
  const filePath = join(root, mapping.file);
366
472
  const fileExists = existsSync(filePath);
473
+ const baseSource = baseSources.get(id) ?? null;
474
+ const remoteSource = remoteSources.get(id) ?? null;
367
475
  // local = fresh extract, with NON_LITERAL props refilled from base.
368
476
  let local;
477
+ // The same tree as the file reads it (linked instances still Customs):
478
+ // what edits to the file are computed against.
479
+ let localFile = null;
369
480
  let fileSource = null;
481
+ /**
482
+ * The file as the developer left it, after import healing (a stripped
483
+ * import is an interrupted sync's damage, not a code edit) and before
484
+ * tagging (an element added without a `$sid` IS one).
485
+ */
486
+ let originalSource = null;
370
487
  if (fileExists) {
371
488
  fileSource = readFileSync(filePath, 'utf8');
489
+ // Pre-extraction import healing: a file left behind by an interrupted
490
+ // sync may use elements its import block never gained. Without the
491
+ // import, extraction reads primitives as local Customs and the merge
492
+ // would push that misreading to the server. The BASE tree knows the true
493
+ // types for every existing sid.
494
+ if (baseEntity) {
495
+ const healed = upsertImports(fileSource, importNeedsFor(linker.unlink(baseEntity.root, mapping.file, () => undefined)));
496
+ if (healed !== fileSource) {
497
+ fileSource = healed;
498
+ writeFileSync(filePath, healed);
499
+ report.notes.push(`${mapping.file}: restored missing imports before extraction`);
500
+ }
501
+ }
502
+ originalSource = fileSource;
503
+ // An element the developer added without a `$sid` is invisible to
504
+ // extraction — and so to the Creator — for as long as it stays untagged.
505
+ // Tag it the way adoption did the file: react-native primitives swapped
506
+ // and their props translated, anything else an opaque Custom node.
507
+ const tagged = codemodAdopt(fileSource, {
508
+ mapping: PRIMITIVE_MAPPING,
509
+ mintSid: mintCid,
510
+ isLinked: (origin) => linker.isLinked(origin, mapping.file),
511
+ });
512
+ if (tagged.adopted.length > 0) {
513
+ fileSource = tagged.source;
514
+ writeFileSync(filePath, fileSource);
515
+ const names = tagged.adopted.map((a) => a.kind === 'custom' ? `${a.originalName} (Custom)` : a.originalName);
516
+ report.notes.push(`${mapping.file}: tagged ${tagged.adopted.length} new element(s) — ${names.join(', ')}`);
517
+ for (const skip of tagged.skipped) {
518
+ report.warnings.push({
519
+ kind: 'UNKNOWN_ELEMENT_WITH_SID',
520
+ detail: `${mapping.file}: ${skip.reason}`,
521
+ });
522
+ }
523
+ }
372
524
  const extracted = extract(fileSource);
373
525
  for (const w of extracted.warnings)
374
526
  report.warnings.push({ ...w, file: mapping.file });
375
527
  local = extracted.dom;
376
- if (local && basePage)
377
- fillNonLiteralProps(local, basePage.root, extracted.warnings);
528
+ if (local) {
529
+ // A hand-written `$sid` that is neither a server id nor a ULID-shaped
530
+ // `c_` placeholder fails the server's envelope validation and takes
531
+ // the whole push down with it. Mint the placeholder here instead.
532
+ const renamed = normalizeClientIds(local);
533
+ if (renamed.size > 0) {
534
+ for (const [from, to] of renamed)
535
+ fileSource = fileSource.split(`"${from}"`).join(`"${to}"`);
536
+ writeFileSync(filePath, fileSource);
537
+ report.notes.push(`${mapping.file}: minted client id(s) for ${[...renamed.keys()].join(', ')} (a new element's $sid must be c_<ULID>)`);
538
+ }
539
+ localFile = structuredClone(local);
540
+ linker.link(local, mapping.file, warnInto(report, mapping.file));
541
+ }
542
+ if (local && baseEntity)
543
+ fillNonLiteralProps(local, baseEntity.root, extracted.warnings);
378
544
  }
379
545
  else {
380
- local = basePage?.root ?? null; // no file -> treat as unchanged
381
- if (basePage) {
546
+ local = baseEntity?.root ?? null; // no file -> treat as unchanged
547
+ if (baseEntity) {
382
548
  report.notes.push(`${mapping.file} is missing — regenerating from the merged canvas state`);
383
549
  }
384
550
  }
385
- const baseRoot = basePage?.root ?? null;
386
- const remoteRoot = remotePage?.root ?? null;
551
+ const baseRoot = baseEntity?.root ?? null;
552
+ const remoteRoot = remoteEntity?.root ?? null;
387
553
  const { merged, conflicts } = threeWayMerge(baseRoot, local, remoteRoot);
388
554
  if (conflicts.length > 0) {
389
555
  let deprecatedTo;
@@ -391,89 +557,201 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
391
557
  deprecatedTo = relative(root, deprecateFile(filePath));
392
558
  for (const c of conflicts)
393
559
  report.conflicts.push({ ...c, file: mapping.file, deprecatedTo });
394
- if (remotePage) {
560
+ if (remoteEntity) {
395
561
  mkdirSync(dirname(filePath), { recursive: true });
396
- writeFileSync(filePath, await emitPage({ name: remotePage.name, root: remotePage.root }, filePath));
562
+ const regenerated = await fileTextFor(remoteEntity, mapping, remoteSource, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath);
563
+ writeFileSync(filePath, regenerated);
564
+ recordSource(id, remoteSource, regenerated, false);
397
565
  report.files.push({
398
566
  file: mapping.file,
399
567
  action: 'deprecated',
400
568
  detail: `conflict — your version kept at ${deprecatedTo ?? '(no file)'}; regenerated from Creator`,
401
569
  });
402
- mergedBase.pages.set(pageId, remotePage);
403
- mapping.name = remotePage.name;
404
- mapping.route = remotePage.route;
570
+ mergedOut.set(id, remoteEntity);
571
+ mapping.name = remoteEntity.name;
405
572
  }
406
573
  else {
407
574
  report.files.push({
408
575
  file: mapping.file,
409
576
  action: 'deprecated',
410
- detail: 'page deleted in Creator; your local edits kept in the .old file',
577
+ detail: `${kind.label} deleted in Creator; your local edits kept in the .old file`,
411
578
  });
412
- delete pagesMap[pageId];
579
+ delete mappings[id];
413
580
  }
414
581
  continue; // keepLocal ops for this file are DROPPED from the push
415
582
  }
416
- if (remotePage === null) {
583
+ if (remoteEntity === null) {
417
584
  // Deleted remotely, no local edits (else it would have conflicted).
418
585
  if (fileExists) {
419
586
  const deprecatedTo = relative(root, deprecateFile(filePath));
420
587
  report.files.push({
421
588
  file: mapping.file,
422
589
  action: 'deprecated',
423
- detail: `page deleted in Creator — file kept at ${deprecatedTo}`,
590
+ detail: `${kind.label} deleted in Creator — file kept at ${deprecatedTo}`,
424
591
  });
425
592
  }
426
- delete pagesMap[pageId];
593
+ delete mappings[id];
427
594
  continue;
428
595
  }
429
596
  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 });
597
+ // Locally deleted tree, remote unchanged: push the deletion.
598
+ pushOps.push({
599
+ op: 'delete',
600
+ entity: 'RenderNode',
601
+ id: remoteEntity.root.sid,
602
+ cascade: true,
603
+ });
604
+ pushOps.push({ op: 'delete', entity: kind.entity, id });
433
605
  report.files.push({
434
606
  file: mapping.file,
435
607
  action: 'updated',
436
608
  detail: 'local deletion pushed to Creator',
437
609
  });
438
- delete pagesMap[pageId];
610
+ delete mappings[id];
439
611
  continue;
440
612
  }
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' });
613
+ // Write the merged view into the file — in the file's language.
614
+ const mergedFile = linker.unlink(merged, mapping.file, warnInto(report, mapping.file));
615
+ /** The file's text after this sync. */
616
+ let written;
617
+ /** The developer changed the file since the last sync (unknowable without a base). */
618
+ let localTextChanged = false;
619
+ if (fileExists && fileSource !== null && localFile !== null && originalSource !== null) {
620
+ // The text 3-way (A12): the code outside `$sid` elements. The
621
+ // document merge above already settled every element; here only the
622
+ // surrounding code is at stake. Remote wins when the developer left the
623
+ // file alone; both changed is a conflict, and P0 keeps the local file.
624
+ const remoteTextChanged = remoteSource !== null && baseSource !== null && remoteSource.text !== baseSource.text;
625
+ // Route markers are the CLI's own (placement rewrites them), never a code edit.
626
+ localTextChanged =
627
+ baseSource !== null &&
628
+ withoutRouteMarker(originalSource) !== withoutRouteMarker(baseSource.text);
629
+ const fromRemote = remoteTextChanged && remoteSource !== null
630
+ ? renderFromSource(remoteSource, mergedFile, mapping.file, report)
631
+ : null;
632
+ if (fromRemote !== null) {
633
+ if (localTextChanged) {
634
+ const deprecatedTo = relative(root, deprecateFile(filePath));
635
+ report.conflicts.push({
636
+ sid: mergedFile.sid,
637
+ reason: 'source-text',
638
+ detail: 'the code around the elements changed both here and in Creator',
639
+ file: mapping.file,
640
+ deprecatedTo,
641
+ });
642
+ report.files.push({
643
+ file: mapping.file,
644
+ action: 'deprecated',
645
+ detail: `code conflict — your version kept at ${deprecatedTo}; Creator's code applied`,
646
+ });
647
+ }
648
+ else {
649
+ report.files.push({
650
+ file: mapping.file,
651
+ action: 'updated',
652
+ detail: 'code edited in Creator',
653
+ });
654
+ }
655
+ mkdirSync(dirname(filePath), { recursive: true });
656
+ writeFileSync(filePath, fromRemote);
657
+ written = fromRemote;
450
658
  }
451
659
  else {
452
- report.files.push({ file: mapping.file, action: 'unchanged' });
660
+ const edits = treeToEdits(localFile, mergedFile);
661
+ // Import healing: patch-inserted elements must be importable, and files
662
+ // touched by an interrupted earlier sync may be missing imports already.
663
+ const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
664
+ if (edits.length > 0 || healed !== fileSource) {
665
+ const { source: patched, warnings } = patch(healed, edits);
666
+ for (const w of warnings)
667
+ report.warnings.push({ ...w, file: mapping.file });
668
+ writeFileSync(filePath, patched);
669
+ report.files.push({ file: mapping.file, action: 'updated' });
670
+ written = patched;
671
+ }
672
+ else {
673
+ report.files.push({ file: mapping.file, action: 'unchanged' });
674
+ written = fileSource;
675
+ }
453
676
  }
454
677
  }
455
678
  else {
456
679
  mkdirSync(dirname(filePath), { recursive: true });
457
- writeFileSync(filePath, await emitPage({ name: remotePage.name, root: merged }, filePath));
680
+ written = await fileTextFor(remoteEntity, mapping, remoteSource, mergedFile, filePath);
681
+ writeFileSync(filePath, written);
458
682
  report.files.push({ file: mapping.file, action: fileExists ? 'regenerated' : 'created' });
459
683
  }
684
+ recordSource(id, remoteSource, written, localTextChanged);
460
685
  // Push = local changes rebased onto remote.
461
686
  pushOps.push(...diffTree(remoteRoot, merged));
462
- mergedBase.pages.set(pageId, { ...remotePage, root: merged });
463
- mapping.name = remotePage.name;
464
- mapping.route = remotePage.route;
687
+ mergedOut.set(id, { ...remoteEntity, root: merged });
688
+ mapping.name = remoteEntity.name;
465
689
  }
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 };
690
+ }
691
+ /** A warning sink that lands each warning in the report stamped with its file. */
692
+ function warnInto(report, file) {
693
+ return (w) => {
694
+ report.warnings.push({ ...w, file });
695
+ };
696
+ }
697
+ /**
698
+ * A container's stored source (A12) brought up to date with `tree` — the
699
+ * FILE-language tree the file must show: its `$sid` elements are re-patched
700
+ * from the document (which is the truth for them), everything around them is
701
+ * the text as stored. Null when the text holds no `$sid` root, which is
702
+ * reported and regenerated rather than silently guessed.
703
+ */
704
+ function renderFromSource(source, tree, file, report) {
705
+ if (source === null)
706
+ return null;
707
+ const extracted = extract(source.text);
708
+ if (extracted.dom === null) {
709
+ report.notes.push(`${file}: the source stored in Creator has no $sid root — regenerated from the canvas instead`);
710
+ return null;
711
+ }
712
+ fillNonLiteralProps(extracted.dom, tree, extracted.warnings);
713
+ const healed = upsertImports(source.text, importNeedsFor(tree));
714
+ const edits = treeToEdits(extracted.dom, tree);
715
+ if (edits.length === 0)
716
+ return healed;
717
+ const { source: patched, warnings } = patch(healed, edits);
718
+ for (const w of warnings)
719
+ report.warnings.push({ ...w, file });
720
+ return patched;
721
+ }
722
+ /**
723
+ * Source ops for the containers the merge marked for upload: a create where
724
+ * the server has no Source yet, an update otherwise. `idMap` resolves
725
+ * containers created by the node push that just landed.
726
+ */
727
+ function sourceOpsFor(mergedBase, remote, uploads, idMap) {
728
+ const ops = [];
729
+ for (const containerId of uploads) {
730
+ const serverId = idMap[containerId] ?? containerId;
731
+ if (serverId.startsWith('c_'))
732
+ continue; // its page never landed (offline): next sync
733
+ const source = mergedBase.sources.get(serverId);
734
+ if (!source)
735
+ throw new Error(`internal: no merged source for ${serverId}`);
736
+ const remoteSource = remote.sources.get(serverId) ?? null;
737
+ if (remoteSource === null) {
738
+ ops.push({
739
+ op: 'create',
740
+ entity: 'Source',
741
+ cid: mintCid(),
742
+ data: { of: serverId, lang: source.lang, text: source.text },
743
+ });
744
+ }
745
+ else if (remoteSource.text !== source.text) {
746
+ ops.push({
747
+ op: 'update',
748
+ entity: 'Source',
749
+ id: remoteSource.id,
750
+ patch: { text: source.text },
751
+ });
752
+ }
753
+ }
754
+ return ops;
477
755
  }
478
756
  /** NON_LITERAL_PROP contract: expression props keep their last-known base value. */
479
757
  function fillNonLiteralProps(local, baseRoot, warnings) {
@@ -501,13 +779,35 @@ function fillNonLiteralProps(local, baseRoot, warnings) {
501
779
  }
502
780
  }
503
781
  }
782
+ // ------------------------------------------------------------ client ids
783
+ /** Ids the server accepts as-is (RFC-0001 §5): a DB id, or a ULID-shaped `c_` placeholder. */
784
+ const SERVER_ID_RE = /^(nd|pg|cmp|rt|st|th|ty|sz|as|nav)_\w+$/;
785
+ const CLIENT_ID_RE = /^c_[0-9A-HJKMNP-TV-Z]{26}$/;
786
+ /**
787
+ * Replace every sid the server would reject with a freshly minted `c_` id,
788
+ * in place. Returns old -> new so the caller can rewrite the file the same
789
+ * way `rewriteIds` does after a push.
790
+ */
791
+ export function normalizeClientIds(root) {
792
+ const renamed = new Map();
793
+ const walk = (node) => {
794
+ if (!SERVER_ID_RE.test(node.sid) && !CLIENT_ID_RE.test(node.sid)) {
795
+ const fresh = mintCid();
796
+ renamed.set(node.sid, fresh);
797
+ node.sid = fresh;
798
+ }
799
+ node.children.forEach(walk);
800
+ };
801
+ walk(root);
802
+ return renamed;
803
+ }
504
804
  // ------------------------------------------------------------ idMap rewrite
505
805
  /** 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) {
806
+ function rewriteIds(root, mergedBase, fileMaps, idMap, report) {
507
807
  const entries = Object.entries(idMap).filter(([cid]) => cid.startsWith('c_'));
508
808
  if (entries.length === 0)
509
809
  return;
510
- for (const mapping of Object.values(pagesMap)) {
810
+ for (const mapping of fileMaps.flatMap((m) => Object.values(m))) {
511
811
  const filePath = join(root, mapping.file);
512
812
  if (!existsSync(filePath))
513
813
  continue;
@@ -530,6 +830,19 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
530
830
  };
531
831
  for (const page of mergedBase.pages.values())
532
832
  renameTree(page.root);
833
+ for (const component of mergedBase.components.values())
834
+ renameTree(component.root);
835
+ // Source text mirrors the files: same rewrite, and a container minted by
836
+ // this push re-keys its entry under the server id.
837
+ const renamedSources = new Map();
838
+ for (const [containerId, source] of mergedBase.sources) {
839
+ let text = source.text;
840
+ for (const [cid, id] of entries)
841
+ text = text.split(`"${cid}"`).join(`"${id}"`);
842
+ const of = idMap[containerId] ?? containerId;
843
+ renamedSources.set(of, { ...source, id: sourceIdFor(of), of, text });
844
+ }
845
+ mergedBase.sources = renamedSources;
533
846
  if (mergedBase.assets) {
534
847
  mergedBase.assets = resolveAssetIds(mergedBase.assets, idMap);
535
848
  writeAssetMap(root, mergedBase.assets);
@@ -537,58 +850,86 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
537
850
  report.notes.push(`resolved ${entries.length} new node id(s) from the server`);
538
851
  }
539
852
  // ----------------------------------------------------------------- finalize
540
- function remoteAsBase(base, remote) {
853
+ function remoteAsBase(remote) {
541
854
  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: {},
855
+ pages: remote.pages,
856
+ components: remote.components,
857
+ navigation: remote.navigation,
858
+ style: remote.style,
859
+ assets: remote.assets,
860
+ sources: new Map([...remote.sources].map(([id, source]) => [id, { ...source, onServer: true }])),
550
861
  };
551
862
  }
552
- function finalize(root, baseStore, mergedBase, pagesMap, head, seq) {
863
+ function finalize(root, baseStore, mergedBase, pagesMap, componentsMap, head, seq) {
553
864
  baseStore.writeAll(mergedBase);
865
+ baseStore.writeStamp(head);
554
866
  writePagesMap(root, pagesMap);
867
+ writeComponentsMap(root, componentsMap);
555
868
  writeHead(root, { head, seq });
556
869
  }
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) {
870
+ function newProgress(head, seq) {
871
+ return { head, seq, idMap: {}, committedOps: 0 };
872
+ }
873
+ /**
874
+ * POST `ops` as sequential transactions of at most MAX_OPS_PER_TX (RFC-0001
875
+ * §5.1 TX_TOO_LARGE discipline). cids are transaction-scoped (§5.2, invariant
876
+ * 5): a later chunk may reference the nodes an earlier chunk created only by
877
+ * their server ids, so every chunk is rewritten through the idMaps collected
878
+ * so far before it is sent — otherwise the server answers CID_UNRESOLVED.
879
+ *
880
+ * `progress` advances after each committed chunk; when this throws, it tells
881
+ * the caller exactly what already landed. The first chunk reuses `firstTxId`
882
+ * (the caller's replay handle, RFC-0001 §7.3); later chunks mint their own.
883
+ * `rebaseInPlace` retries a 409 on the same chunk from the server's head (the
884
+ * outbox drain has nothing local to re-merge); otherwise the 409 propagates so
885
+ * the caller can re-merge against the advanced remote.
886
+ */
887
+ async function pushChunked(client, ops, firstTxId, progress, opts) {
888
+ for (let i = 0; i < ops.length; i += MAX_OPS_PER_TX) {
889
+ const chunk = resolveCids(ops.slice(i, i + MAX_OPS_PER_TX), progress.idMap);
890
+ const txId = i === 0 ? firstTxId : mintTxId();
891
+ let cursor = progress.head;
892
+ let result;
893
+ for (let attempt = 0; result === undefined; attempt++) {
894
+ try {
895
+ result = await commitWithTransportRetry(client, buildEnvelope(client, { base: cursor, ops: chunk, txId }));
896
+ }
897
+ catch (err) {
898
+ if (!opts.rebaseInPlace || !(err instanceof HeadMismatchError))
899
+ throw err;
900
+ if (attempt + 1 >= MAX_REBASE_RETRIES) {
901
+ throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
902
+ }
566
903
  cursor = err.head;
567
- report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
568
- continue;
904
+ opts.report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
569
905
  }
570
- throw err;
571
906
  }
907
+ progress.head = result.head;
908
+ progress.seq = result.seq;
909
+ progress.idMap = { ...progress.idMap, ...result.idMap };
910
+ progress.committedOps += chunk.length;
911
+ if (result.warnings?.length) {
912
+ for (const w of result.warnings)
913
+ opts.report.notes.push(`server warning: ${w}`);
914
+ }
915
+ opts.onChunk?.(result.idMap);
572
916
  }
573
- throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
574
917
  }
575
918
  function parkOffline(root, base, outbox, report) {
576
919
  // Offline before we could pull: compute local ops vs base and park them.
577
- const pagesMap = readPagesMap(root);
578
920
  const ops = [];
579
- for (const [pageId, mapping] of Object.entries(pagesMap)) {
580
- const basePage = base.pages.get(pageId);
581
- if (!basePage)
582
- continue;
921
+ const linker = instanceLinker(readComponentsMap(root), base.components);
922
+ for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
583
923
  const filePath = join(root, mapping.file);
584
924
  if (!existsSync(filePath))
585
925
  continue;
586
926
  const extracted = extract(readFileSync(filePath, 'utf8'));
587
927
  for (const w of extracted.warnings)
588
928
  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));
929
+ if (extracted.dom) {
930
+ linker.link(extracted.dom, mapping.file, warnInto(report, mapping.file));
931
+ fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
932
+ ops.push(...diffTree(baseEntity.root, extracted.dom));
592
933
  }
593
934
  }
594
935
  if (ops.length > 0) {
@@ -598,20 +939,36 @@ function parkOffline(root, base, outbox, report) {
598
939
  report.pushedOps = ops.length;
599
940
  return report;
600
941
  }
942
+ /** Every mapped page and component that exists in the base, with its mapping. */
943
+ function mappedEntities(root, base) {
944
+ const out = [];
945
+ for (const [id, mapping] of Object.entries(readPagesMap(root))) {
946
+ const page = base.pages.get(id);
947
+ if (page)
948
+ out.push([id, mapping, page]);
949
+ }
950
+ for (const [id, mapping] of Object.entries(readComponentsMap(root))) {
951
+ const component = base.components.get(id);
952
+ if (component)
953
+ out.push([id, mapping, component]);
954
+ }
955
+ return out;
956
+ }
601
957
  export function verifyNoLocalDrift(root) {
602
958
  // Used by doctor (Phase 8): re-extract every mapped file and compare to base.
603
959
  const base = new BaseStore(root).readAll();
604
- const pagesMap = readPagesMap(root);
960
+ const linker = instanceLinker(readComponentsMap(root), base.components);
605
961
  const results = [];
606
- for (const [pageId, mapping] of Object.entries(pagesMap)) {
607
- const basePage = base.pages.get(pageId);
962
+ for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
608
963
  const filePath = join(root, mapping.file);
609
- if (!basePage || !existsSync(filePath))
964
+ if (!existsSync(filePath))
610
965
  continue;
611
966
  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) });
967
+ if (extracted.dom) {
968
+ linker.link(extracted.dom, mapping.file, () => undefined);
969
+ fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
970
+ }
971
+ results.push({ file: mapping.file, drifted: !deepEqual(extracted.dom, baseEntity.root) });
615
972
  }
616
973
  return results;
617
974
  }