mapples 0.2.0-beta.3 → 0.2.0-beta.5

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 (43) hide show
  1. package/dist/api/objects.d.ts +3 -1
  2. package/dist/api/objects.js +24 -1
  3. package/dist/api/objects.js.map +1 -1
  4. package/dist/api/txPlan.d.ts +15 -0
  5. package/dist/api/txPlan.js +196 -0
  6. package/dist/api/txPlan.js.map +1 -0
  7. package/dist/codegen/emit.d.ts +2 -31
  8. package/dist/codegen/emit.js +7 -147
  9. package/dist/codegen/emit.js.map +1 -1
  10. package/dist/codegen/imports.d.ts +1 -17
  11. package/dist/codegen/imports.js +2 -116
  12. package/dist/codegen/imports.js.map +1 -1
  13. package/dist/codegen/instances.d.ts +2 -13
  14. package/dist/codegen/instances.js +23 -134
  15. package/dist/codegen/instances.js.map +1 -1
  16. package/dist/codegen/styles.d.ts +1 -11
  17. package/dist/codegen/styles.js +2 -130
  18. package/dist/codegen/styles.js.map +1 -1
  19. package/dist/merge/applyOps.d.ts +4 -1
  20. package/dist/merge/applyOps.js +42 -30
  21. package/dist/merge/applyOps.js.map +1 -1
  22. package/dist/merge/childrenOrder.d.ts +13 -0
  23. package/dist/merge/childrenOrder.js +51 -0
  24. package/dist/merge/childrenOrder.js.map +1 -0
  25. package/dist/merge/threeWay.d.ts +1 -1
  26. package/dist/merge/toEdits.d.ts +1 -3
  27. package/dist/merge/toEdits.js +3 -94
  28. package/dist/merge/toEdits.js.map +1 -1
  29. package/dist/project/baseStore.d.ts +11 -1
  30. package/dist/project/baseStore.js +13 -0
  31. package/dist/project/baseStore.js.map +1 -1
  32. package/dist/services/adoptService.js +11 -0
  33. package/dist/services/adoptService.js.map +1 -1
  34. package/dist/services/syncEngine.d.ts +1 -1
  35. package/dist/services/syncEngine.js +297 -60
  36. package/dist/services/syncEngine.js.map +1 -1
  37. package/dist/types.d.ts +28 -1
  38. package/dist/types.js +3 -0
  39. package/dist/types.js.map +1 -1
  40. package/dist/ui/report.d.ts +7 -0
  41. package/dist/ui/report.js +8 -0
  42. package/dist/ui/report.js.map +1 -1
  43. package/package.json +3 -2
@@ -13,10 +13,11 @@ import { codemodAdopt, extract, patch, upsertImports } from '@mapples/sid-ast';
13
13
  import { isNetworkError } from '../api/client.js';
14
14
  import { buildEnvelope, commitWithTransportRetry, mintCid, mintTxId, resolveCids, } from '../api/commit.js';
15
15
  import { HeadMismatchError, JpatchError } from '../api/errors.js';
16
+ import { planTransactions } from '../api/txPlan.js';
16
17
  import { pullDelta, SnapshotRequired } from '../api/poll.js';
17
18
  import { fetchProjectState, } from '../api/objects.js';
18
19
  import { PRIMITIVE_MAPPING } from '../adopt/mappingTable.js';
19
- import { emitComponent, emitPage } from '../codegen/emit.js';
20
+ import { emitComponent, emitPage, withoutRouteMarker } from '../codegen/emit.js';
20
21
  import { importNeedsFor } from '../codegen/imports.js';
21
22
  import { instanceLinker } from '../codegen/instances.js';
22
23
  import { deprecateFile } from '../merge/deprecate.js';
@@ -35,8 +36,10 @@ import { mintEntityFile } from '../nav/placement.js';
35
36
  import { syncNavigation } from '../nav/routeTree.js';
36
37
  import { syncStyleFile } from '../style/theme.js';
37
38
  import { emptyReport } from '../ui/report.js';
39
+ import { sourceIdFor, } from '../types.js';
38
40
  const MAX_REBASE_RETRIES = 5;
39
- 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;
40
43
  // buildRemoteModel needs the project root to read the stored head; carried as
41
44
  // a non-enumerable tag so BaseSnapshot's shape stays unchanged for callers.
42
45
  const ROOT_TAG = Symbol('projectRoot');
@@ -65,8 +68,9 @@ export async function runSyncEngine({ root, client }) {
65
68
  let currentSeq = head.seq;
66
69
  for (const entry of outbox.list()) {
67
70
  const progress = newProgress(currentHead, currentSeq);
71
+ const entryTxs = planTransactions(entry.ops);
68
72
  try {
69
- await pushChunked(client, entry.ops, entry.txId, progress, { rebaseInPlace: true, report });
73
+ await pushChunked(client, entryTxs, entry.txId, progress, { rebaseInPlace: true, report });
70
74
  currentHead = progress.head;
71
75
  currentSeq = progress.seq;
72
76
  outbox.clear(entry.txId);
@@ -80,7 +84,7 @@ export async function runSyncEngine({ root, client }) {
80
84
  outbox.clear(entry.txId);
81
85
  outbox.park({
82
86
  txId: mintTxId(),
83
- ops: resolveCids(entry.ops.slice(progress.committedOps), progress.idMap),
87
+ ops: resolveCids(entryTxs.flat().slice(progress.committedOps), progress.idMap),
84
88
  parkedAt: entry.parkedAt,
85
89
  });
86
90
  }
@@ -112,27 +116,62 @@ export async function runSyncEngine({ root, client }) {
112
116
  let txId = mintTxId();
113
117
  let attempt = 0;
114
118
  for (;;) {
115
- const { pushOps, mergedBase, pagesMap, componentsMap } = await mergeAndWrite(root, client, config, base, remote, report);
116
- if (pushOps.length === 0) {
119
+ const merged = await mergeAndWrite(root, client, config, base, remote, report);
120
+ const { mergedBase, pagesMap, componentsMap, sourceUploads } = merged;
121
+ // Planned before anything is sent: a placement group that cannot fit one
122
+ // transaction stops the push here (RFC-0001 §5.2). `pushOps` is the send
123
+ // order, which is what `progress.committedOps` counts against.
124
+ const pushTxs = planTransactions(merged.pushOps);
125
+ const pushOps = pushTxs.flat();
126
+ // Source text goes out AFTER the node ops (its `$sid`s must be server
127
+ // ids, which the node push mints), as its own transaction(s).
128
+ let sourceOps = [];
129
+ if (pushOps.length === 0 && sourceUploads.size === 0) {
117
130
  finalize(root, baseStore, mergedBase, pagesMap, componentsMap, remote.head, remote.seq);
118
131
  report.head = remote.head;
119
132
  return report;
120
133
  }
121
- if (pushOps.length > MAX_OPS_PER_TX) {
134
+ if (pushTxs.length > 1) {
122
135
  // Split into sequential transactions (RFC-0001 §5.1 TX_TOO_LARGE discipline).
123
- report.notes.push(`push split into ${Math.ceil(pushOps.length / MAX_OPS_PER_TX)} transactions`);
136
+ report.notes.push(`push split into ${pushTxs.length} transactions`);
124
137
  }
125
138
  const progress = newProgress(remote.head, remote.seq);
126
139
  try {
127
- await pushChunked(client, pushOps, txId, progress, {
128
- rebaseInPlace: false,
129
- report,
130
- // Rewrite as each chunk lands, so files and base never hold a cid the
131
- // server has already resolved: a re-merge or a park after a later
132
- // chunk fails then sees the committed nodes by their real ids.
133
- onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
134
- });
135
- report.pushedOps += pushOps.length;
140
+ if (pushOps.length > 0) {
141
+ await pushChunked(client, pushTxs, txId, progress, {
142
+ rebaseInPlace: false,
143
+ report,
144
+ // Rewrite as each chunk lands, so files and base never hold a cid the
145
+ // server has already resolved: a re-merge or a park after a later
146
+ // chunk fails then sees the committed nodes by their real ids.
147
+ onChunk: (idMap) => rewriteIds(root, mergedBase, [pagesMap, componentsMap], idMap, report),
148
+ });
149
+ report.pushedOps += pushOps.length;
150
+ }
151
+ sourceOps = sourceOpsFor(mergedBase, remote, sourceUploads, progress.idMap);
152
+ try {
153
+ for (let i = 0; i < sourceOps.length; i += SOURCE_OPS_PER_TX) {
154
+ await pushChunked(client, [sourceOps.slice(i, i + SOURCE_OPS_PER_TX)], pushOps.length === 0 && i === 0 ? txId : mintTxId(), progress, { rebaseInPlace: false, report });
155
+ }
156
+ if (sourceOps.length > 0) {
157
+ report.pushedOps += sourceOps.length;
158
+ report.notes.push(`uploaded the source text of ${sourceOps.length} file(s) to Creator`);
159
+ }
160
+ }
161
+ catch (err) {
162
+ if (!(err instanceof JpatchError && err.code === 'UNKNOWN_ENTITY'))
163
+ throw err;
164
+ // A Creator server from before A12 rejects the entity. The node ops
165
+ // already landed; the code stays local and is offered again once the
166
+ // server stores source text — never silently.
167
+ for (const id of sourceUploads) {
168
+ const entry = mergedBase.sources.get(progress.idMap[id] ?? id);
169
+ if (entry)
170
+ entry.onServer = false;
171
+ }
172
+ sourceOps = [];
173
+ report.notes.push('this Creator server does not store source text yet — the code around your elements stays local until it does');
174
+ }
136
175
  finalize(root, baseStore, mergedBase, pagesMap, componentsMap, progress.head, progress.seq);
137
176
  report.head = progress.head;
138
177
  return report;
@@ -157,12 +196,15 @@ export async function runSyncEngine({ root, client }) {
157
196
  // Files already show the merged view; park what did not land (with
158
197
  // the committed chunks' cids resolved) and adopt the remote head so
159
198
  // the drain pushes exactly these ops next time.
199
+ // Source ops not yet computed (offline during the node push) are
200
+ // simply recomputed next time: the files still differ from the base.
201
+ const allOps = [...pushOps, ...sourceOps];
160
202
  outbox.park({
161
203
  txId: progress.committedOps > 0 ? mintTxId() : txId,
162
- ops: resolveCids(pushOps.slice(progress.committedOps), progress.idMap),
204
+ ops: resolveCids(allOps.slice(progress.committedOps), progress.idMap),
163
205
  parkedAt: new Date().toISOString(),
164
206
  });
165
- report.pushedOps = pushOps.length;
207
+ report.pushedOps = allOps.length;
166
208
  report.offline = true;
167
209
  finalize(root, baseStore, remoteAsBase(remote), readPagesMap(root), readComponentsMap(root), remote.head, remote.seq);
168
210
  return report;
@@ -182,26 +224,33 @@ async function bootstrap(root, client, config, report) {
182
224
  const componentsMap = readComponentsMap(root);
183
225
  placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, state.components, componentsMap);
184
226
  const linker = instanceLinker(componentsMap, state.components);
227
+ // A container with stored source text (A12) materializes from that text —
228
+ // hooks and handlers written in the Creator's editor included — patched to
229
+ // its current tree; the rest is generated.
185
230
  const targets = [];
186
231
  for (const page of state.pages.values()) {
187
232
  const mapping = pagesMap[page.id];
188
233
  if (!mapping)
189
234
  throw new Error(`internal: page ${page.id} was not placed`);
235
+ const tree = linker.unlink(page.root, mapping.file, warnInto(report, mapping.file));
190
236
  targets.push({
237
+ id: page.id,
191
238
  file: mapping.file,
192
- emit: () => emitPage(emitInput(page.name, linker.unlink(page.root, mapping.file, warnInto(report, mapping.file)), mapping), join(root, mapping.file)),
239
+ emit: async () => renderFromSource(state.sources.get(page.id) ?? null, tree, mapping.file, report) ??
240
+ emitPage(emitInput(page.name, tree, mapping), join(root, mapping.file)),
193
241
  });
194
242
  }
195
243
  for (const component of state.components.values()) {
196
244
  const mapping = componentsMap[component.id];
245
+ const tree = linker.unlink(component.root, mapping.file, warnInto(report, mapping.file));
197
246
  targets.push({
247
+ id: component.id,
198
248
  file: mapping.file,
199
- emit: () => emitComponent({
200
- name: component.name,
201
- root: linker.unlink(component.root, mapping.file, warnInto(report, mapping.file)),
202
- }, join(root, mapping.file)),
249
+ emit: async () => renderFromSource(state.sources.get(component.id) ?? null, tree, mapping.file, report) ??
250
+ emitComponent({ name: component.name, root: tree }, join(root, mapping.file)),
203
251
  });
204
252
  }
253
+ const baseSources = new Map();
205
254
  for (const target of targets) {
206
255
  const filePath = join(root, target.file);
207
256
  if (existsSync(filePath)) {
@@ -211,11 +260,23 @@ async function bootstrap(root, client, config, report) {
211
260
  action: 'unchanged',
212
261
  detail: 'exists — left untouched',
213
262
  });
214
- continue;
215
263
  }
216
- mkdirSync(dirname(filePath), { recursive: true });
217
- writeFileSync(filePath, await target.emit());
218
- report.files.push({ file: target.file, action: 'created' });
264
+ else {
265
+ mkdirSync(dirname(filePath), { recursive: true });
266
+ writeFileSync(filePath, await target.emit());
267
+ report.files.push({ file: target.file, action: 'created' });
268
+ }
269
+ // The text 3-way's base: the server's text where it has one, else the
270
+ // file as it stands (an untouched pre-existing file included — its own
271
+ // code then reads as a local change and uploads on the next sync).
272
+ const remoteSource = state.sources.get(target.id) ?? null;
273
+ baseSources.set(target.id, {
274
+ id: remoteSource?.id ?? sourceIdFor(target.id),
275
+ of: target.id,
276
+ lang: remoteSource?.lang ?? 'tsx',
277
+ text: remoteSource?.text ?? readFileSync(filePath, 'utf8'),
278
+ onServer: remoteSource !== null,
279
+ });
219
280
  }
220
281
  const styleResult = syncStyleFile(root, state.style, state.style, report);
221
282
  const assetsResult = await syncAssets(root, client, state.assets, report);
@@ -228,6 +289,7 @@ async function bootstrap(root, client, config, report) {
228
289
  navigation: navResult.mergedNavigation,
229
290
  style: styleResult.mergedStyle,
230
291
  assets: state.assets,
292
+ sources: baseSources,
231
293
  });
232
294
  baseStore.writeStamp(state.head);
233
295
  writePagesMap(root, pagesMap);
@@ -287,6 +349,9 @@ async function buildRemoteModel(client, base, sinceSeq, report) {
287
349
  navigation: structuredClone(base.navigation),
288
350
  style: structuredClone(base.style),
289
351
  assets: structuredClone(base.assets),
352
+ sources: new Map([...base.sources]
353
+ .filter(([, source]) => source.onServer)
354
+ .map(([id, { onServer: _onServer, ...source }]) => [id, source])),
290
355
  head: storedHead.head,
291
356
  seq: storedHead.seq,
292
357
  };
@@ -299,6 +364,7 @@ async function buildRemoteModel(client, base, sinceSeq, report) {
299
364
  navigation: state.navigation,
300
365
  style: state.style,
301
366
  assets: state.assets,
367
+ sources: state.sources,
302
368
  head: state.head,
303
369
  seq: state.seq,
304
370
  };
@@ -309,12 +375,14 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
309
375
  const pagesMap = readPagesMap(root);
310
376
  const componentsMap = readComponentsMap(root);
311
377
  const pushOps = [];
378
+ const sourceUploads = new Set();
312
379
  const mergedBase = {
313
380
  pages: new Map(),
314
381
  components: new Map(),
315
382
  navigation: remote.navigation,
316
383
  style: remote.style,
317
384
  assets: remote.assets,
385
+ sources: new Map(),
318
386
  };
319
387
  // Reset per-attempt report fields (the rebase loop re-runs this).
320
388
  report.files = [];
@@ -336,6 +404,10 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
336
404
  linker,
337
405
  emit: (entity, mapping, tree, filePath) => emitPage(emitInput(entity.name, tree, mapping), filePath),
338
406
  mergedOut: mergedBase.pages,
407
+ baseSources: base.sources,
408
+ remoteSources: remote.sources,
409
+ sourcesOut: mergedBase.sources,
410
+ sourceUploads,
339
411
  pushOps,
340
412
  report,
341
413
  });
@@ -348,6 +420,10 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
348
420
  linker,
349
421
  emit: (entity, _mapping, tree, filePath) => emitComponent({ name: entity.name, root: tree }, filePath),
350
422
  mergedOut: mergedBase.components,
423
+ baseSources: base.sources,
424
+ remoteSources: remote.sources,
425
+ sourcesOut: mergedBase.sources,
426
+ sourceUploads,
351
427
  pushOps,
352
428
  report,
353
429
  });
@@ -358,7 +434,7 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
358
434
  const assetsResult = await syncAssets(root, client, remote.assets, report);
359
435
  pushOps.push(...assetsResult.pushOps);
360
436
  mergedBase.assets = assetsResult.mergedAssets;
361
- return { pushOps, mergedBase, pagesMap, componentsMap };
437
+ return { pushOps, mergedBase, pagesMap, componentsMap, sourceUploads };
362
438
  }
363
439
  /**
364
440
  * The 3-way file merge for one entity kind: extract the file, merge against
@@ -366,7 +442,28 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
366
442
  * deprecate on conflict, and collect the ops that push local changes.
367
443
  */
368
444
  async function mergeFiles(args) {
369
- const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, pushOps, report, } = args;
445
+ const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, baseSources, remoteSources, sourcesOut, sourceUploads, pushOps, report, } = args;
446
+ /**
447
+ * The file's text after this sync becomes the next base. It goes to the
448
+ * server when a stored text exists and differs from the file (the file
449
+ * always wins: the merge above already folded the Creator's side in), or
450
+ * when the developer changed the code of a file the server has no text for.
451
+ */
452
+ const recordSource = (id, remoteSource, text, codeChanged) => {
453
+ const upload = remoteSource !== null ? remoteSource.text !== text : codeChanged;
454
+ if (upload)
455
+ sourceUploads.add(id);
456
+ sourcesOut.set(id, {
457
+ id: remoteSource?.id ?? sourceIdFor(id),
458
+ of: id,
459
+ lang: remoteSource?.lang ?? 'tsx',
460
+ text,
461
+ onServer: remoteSource !== null || upload,
462
+ });
463
+ };
464
+ /** A container's file text: its stored source patched to `tree`, else generated. */
465
+ const fileTextFor = async (entity, mapping, remoteSource, tree, filePath) => renderFromSource(remoteSource, tree, mapping.file, report) ??
466
+ (await emit(entity, mapping, tree, filePath));
370
467
  const ids = new Set([...base.keys(), ...remote.keys()]);
371
468
  for (const id of ids) {
372
469
  const baseEntity = base.get(id) ?? null;
@@ -380,12 +477,20 @@ async function mergeFiles(args) {
380
477
  }
381
478
  const filePath = join(root, mapping.file);
382
479
  const fileExists = existsSync(filePath);
480
+ const baseSource = baseSources.get(id) ?? null;
481
+ const remoteSource = remoteSources.get(id) ?? null;
383
482
  // local = fresh extract, with NON_LITERAL props refilled from base.
384
483
  let local;
385
484
  // The same tree as the file reads it (linked instances still Customs):
386
485
  // what edits to the file are computed against.
387
486
  let localFile = null;
388
487
  let fileSource = null;
488
+ /**
489
+ * The file as the developer left it, after import healing (a stripped
490
+ * import is an interrupted sync's damage, not a code edit) and before
491
+ * tagging (an element added without a `$sid` IS one).
492
+ */
493
+ let originalSource = null;
389
494
  if (fileExists) {
390
495
  fileSource = readFileSync(filePath, 'utf8');
391
496
  // Pre-extraction import healing: a file left behind by an interrupted
@@ -401,6 +506,7 @@ async function mergeFiles(args) {
401
506
  report.notes.push(`${mapping.file}: restored missing imports before extraction`);
402
507
  }
403
508
  }
509
+ originalSource = fileSource;
404
510
  // An element the developer added without a `$sid` is invisible to
405
511
  // extraction — and so to the Creator — for as long as it stays untagged.
406
512
  // Tag it the way adoption did the file: react-native primitives swapped
@@ -460,7 +566,9 @@ async function mergeFiles(args) {
460
566
  report.conflicts.push({ ...c, file: mapping.file, deprecatedTo });
461
567
  if (remoteEntity) {
462
568
  mkdirSync(dirname(filePath), { recursive: true });
463
- writeFileSync(filePath, await emit(remoteEntity, mapping, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath));
569
+ const regenerated = await fileTextFor(remoteEntity, mapping, remoteSource, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath);
570
+ writeFileSync(filePath, regenerated);
571
+ recordSource(id, remoteSource, regenerated, false);
464
572
  report.files.push({
465
573
  file: mapping.file,
466
574
  action: 'deprecated',
@@ -511,27 +619,76 @@ async function mergeFiles(args) {
511
619
  }
512
620
  // Write the merged view into the file — in the file's language.
513
621
  const mergedFile = linker.unlink(merged, mapping.file, warnInto(report, mapping.file));
514
- if (fileExists && fileSource !== null && localFile !== null) {
515
- const edits = treeToEdits(localFile, mergedFile);
516
- // Import healing: patch-inserted elements must be importable, and files
517
- // touched by an interrupted earlier sync may be missing imports already.
518
- const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
519
- if (edits.length > 0 || healed !== fileSource) {
520
- const { source: patched, warnings } = patch(healed, edits);
521
- for (const w of warnings)
522
- report.warnings.push({ ...w, file: mapping.file });
523
- writeFileSync(filePath, patched);
524
- report.files.push({ file: mapping.file, action: 'updated' });
622
+ /** The file's text after this sync. */
623
+ let written;
624
+ /** The developer changed the file since the last sync (unknowable without a base). */
625
+ let localTextChanged = false;
626
+ if (fileExists && fileSource !== null && localFile !== null && originalSource !== null) {
627
+ // The text 3-way (A12): the code outside `$sid` elements. The
628
+ // document merge above already settled every element; here only the
629
+ // surrounding code is at stake. Remote wins when the developer left the
630
+ // file alone; both changed is a conflict, and P0 keeps the local file.
631
+ const remoteTextChanged = remoteSource !== null && baseSource !== null && remoteSource.text !== baseSource.text;
632
+ // Route markers are the CLI's own (placement rewrites them), never a code edit.
633
+ localTextChanged =
634
+ baseSource !== null &&
635
+ withoutRouteMarker(originalSource) !== withoutRouteMarker(baseSource.text);
636
+ const fromRemote = remoteTextChanged && remoteSource !== null
637
+ ? renderFromSource(remoteSource, mergedFile, mapping.file, report)
638
+ : null;
639
+ if (fromRemote !== null) {
640
+ if (localTextChanged) {
641
+ const deprecatedTo = relative(root, deprecateFile(filePath));
642
+ report.conflicts.push({
643
+ sid: mergedFile.sid,
644
+ reason: 'source-text',
645
+ detail: 'the code around the elements changed both here and in Creator',
646
+ file: mapping.file,
647
+ deprecatedTo,
648
+ });
649
+ report.files.push({
650
+ file: mapping.file,
651
+ action: 'deprecated',
652
+ detail: `code conflict — your version kept at ${deprecatedTo}; Creator's code applied`,
653
+ });
654
+ }
655
+ else {
656
+ report.files.push({
657
+ file: mapping.file,
658
+ action: 'updated',
659
+ detail: 'code edited in Creator',
660
+ });
661
+ }
662
+ mkdirSync(dirname(filePath), { recursive: true });
663
+ writeFileSync(filePath, fromRemote);
664
+ written = fromRemote;
525
665
  }
526
666
  else {
527
- report.files.push({ file: mapping.file, action: 'unchanged' });
667
+ const edits = treeToEdits(localFile, mergedFile);
668
+ // Import healing: patch-inserted elements must be importable, and files
669
+ // touched by an interrupted earlier sync may be missing imports already.
670
+ const healed = upsertImports(fileSource, importNeedsFor(mergedFile));
671
+ if (edits.length > 0 || healed !== fileSource) {
672
+ const { source: patched, warnings } = patch(healed, edits);
673
+ for (const w of warnings)
674
+ report.warnings.push({ ...w, file: mapping.file });
675
+ writeFileSync(filePath, patched);
676
+ report.files.push({ file: mapping.file, action: 'updated' });
677
+ written = patched;
678
+ }
679
+ else {
680
+ report.files.push({ file: mapping.file, action: 'unchanged' });
681
+ written = fileSource;
682
+ }
528
683
  }
529
684
  }
530
685
  else {
531
686
  mkdirSync(dirname(filePath), { recursive: true });
532
- writeFileSync(filePath, await emit(remoteEntity, mapping, mergedFile, filePath));
687
+ written = await fileTextFor(remoteEntity, mapping, remoteSource, mergedFile, filePath);
688
+ writeFileSync(filePath, written);
533
689
  report.files.push({ file: mapping.file, action: fileExists ? 'regenerated' : 'created' });
534
690
  }
691
+ recordSource(id, remoteSource, written, localTextChanged);
535
692
  // Push = local changes rebased onto remote.
536
693
  pushOps.push(...diffTree(remoteRoot, merged));
537
694
  mergedOut.set(id, { ...remoteEntity, root: merged });
@@ -544,6 +701,65 @@ function warnInto(report, file) {
544
701
  report.warnings.push({ ...w, file });
545
702
  };
546
703
  }
704
+ /**
705
+ * A container's stored source (A12) brought up to date with `tree` — the
706
+ * FILE-language tree the file must show: its `$sid` elements are re-patched
707
+ * from the document (which is the truth for them), everything around them is
708
+ * the text as stored. Null when the text holds no `$sid` root, which is
709
+ * reported and regenerated rather than silently guessed.
710
+ */
711
+ function renderFromSource(source, tree, file, report) {
712
+ if (source === null)
713
+ return null;
714
+ const extracted = extract(source.text);
715
+ if (extracted.dom === null) {
716
+ report.notes.push(`${file}: the source stored in Creator has no $sid root — regenerated from the canvas instead`);
717
+ return null;
718
+ }
719
+ fillNonLiteralProps(extracted.dom, tree, extracted.warnings);
720
+ const healed = upsertImports(source.text, importNeedsFor(tree));
721
+ const edits = treeToEdits(extracted.dom, tree);
722
+ if (edits.length === 0)
723
+ return healed;
724
+ const { source: patched, warnings } = patch(healed, edits);
725
+ for (const w of warnings)
726
+ report.warnings.push({ ...w, file });
727
+ return patched;
728
+ }
729
+ /**
730
+ * Source ops for the containers the merge marked for upload: a create where
731
+ * the server has no Source yet, an update otherwise. `idMap` resolves
732
+ * containers created by the node push that just landed.
733
+ */
734
+ function sourceOpsFor(mergedBase, remote, uploads, idMap) {
735
+ const ops = [];
736
+ for (const containerId of uploads) {
737
+ const serverId = idMap[containerId] ?? containerId;
738
+ if (serverId.startsWith('c_'))
739
+ continue; // its page never landed (offline): next sync
740
+ const source = mergedBase.sources.get(serverId);
741
+ if (!source)
742
+ throw new Error(`internal: no merged source for ${serverId}`);
743
+ const remoteSource = remote.sources.get(serverId) ?? null;
744
+ if (remoteSource === null) {
745
+ ops.push({
746
+ op: 'create',
747
+ entity: 'Source',
748
+ cid: mintCid(),
749
+ data: { of: serverId, lang: source.lang, text: source.text },
750
+ });
751
+ }
752
+ else if (remoteSource.text !== source.text) {
753
+ ops.push({
754
+ op: 'update',
755
+ entity: 'Source',
756
+ id: remoteSource.id,
757
+ patch: { text: source.text },
758
+ });
759
+ }
760
+ }
761
+ return ops;
762
+ }
547
763
  /** NON_LITERAL_PROP contract: expression props keep their last-known base value. */
548
764
  function fillNonLiteralProps(local, baseRoot, warnings) {
549
765
  const baseIndex = new Map();
@@ -623,6 +839,17 @@ function rewriteIds(root, mergedBase, fileMaps, idMap, report) {
623
839
  renameTree(page.root);
624
840
  for (const component of mergedBase.components.values())
625
841
  renameTree(component.root);
842
+ // Source text mirrors the files: same rewrite, and a container minted by
843
+ // this push re-keys its entry under the server id.
844
+ const renamedSources = new Map();
845
+ for (const [containerId, source] of mergedBase.sources) {
846
+ let text = source.text;
847
+ for (const [cid, id] of entries)
848
+ text = text.split(`"${cid}"`).join(`"${id}"`);
849
+ const of = idMap[containerId] ?? containerId;
850
+ renamedSources.set(of, { ...source, id: sourceIdFor(of), of, text });
851
+ }
852
+ mergedBase.sources = renamedSources;
626
853
  if (mergedBase.assets) {
627
854
  mergedBase.assets = resolveAssetIds(mergedBase.assets, idMap);
628
855
  writeAssetMap(root, mergedBase.assets);
@@ -637,6 +864,7 @@ function remoteAsBase(remote) {
637
864
  navigation: remote.navigation,
638
865
  style: remote.style,
639
866
  assets: remote.assets,
867
+ sources: new Map([...remote.sources].map(([id, source]) => [id, { ...source, onServer: true }])),
640
868
  };
641
869
  }
642
870
  function finalize(root, baseStore, mergedBase, pagesMap, componentsMap, head, seq) {
@@ -650,22 +878,24 @@ function newProgress(head, seq) {
650
878
  return { head, seq, idMap: {}, committedOps: 0 };
651
879
  }
652
880
  /**
653
- * POST `ops` as sequential transactions of at most MAX_OPS_PER_TX (RFC-0001
654
- * §5.1 TX_TOO_LARGE discipline). cids are transaction-scoped (§5.2, invariant
655
- * 5): a later chunk may reference the nodes an earlier chunk created only by
656
- * their server ids, so every chunk is rewritten through the idMaps collected
881
+ * POST `txs` (planned by planTransactions, RFC-0001 §5.1/§5.2) as sequential
882
+ * transactions. cids are transaction-scoped (§5.2, invariant 5): a later
883
+ * transaction may reference the nodes an earlier one created only by their
884
+ * server ids, so every transaction is rewritten through the idMaps collected
657
885
  * so far before it is sent — otherwise the server answers CID_UNRESOLVED.
658
886
  *
659
- * `progress` advances after each committed chunk; when this throws, it tells
660
- * the caller exactly what already landed. The first chunk reuses `firstTxId`
661
- * (the caller's replay handle, RFC-0001 §7.3); later chunks mint their own.
662
- * `rebaseInPlace` retries a 409 on the same chunk from the server's head (the
663
- * outbox drain has nothing local to re-merge); otherwise the 409 propagates so
664
- * the caller can re-merge against the advanced remote.
887
+ * `progress` advances after each committed transaction; when this throws, it
888
+ * tells the caller exactly what already landed (`committedOps` counts against
889
+ * `txs.flat()`). The first transaction reuses `firstTxId` (the caller's replay
890
+ * handle, RFC-0001 §7.3); later ones mint their own. `rebaseInPlace` retries a
891
+ * 409 on the same transaction from the server's head (the outbox drain has
892
+ * nothing local to re-merge); otherwise the 409 propagates so the caller can
893
+ * re-merge against the advanced remote. Every op the server dropped and every
894
+ * warning it raised lands in the report.
665
895
  */
666
- async function pushChunked(client, ops, firstTxId, progress, opts) {
667
- for (let i = 0; i < ops.length; i += MAX_OPS_PER_TX) {
668
- const chunk = resolveCids(ops.slice(i, i + MAX_OPS_PER_TX), progress.idMap);
896
+ async function pushChunked(client, txs, firstTxId, progress, opts) {
897
+ for (const [i, tx] of txs.entries()) {
898
+ const chunk = resolveCids(tx, progress.idMap);
669
899
  const txId = i === 0 ? firstTxId : mintTxId();
670
900
  let cursor = progress.head;
671
901
  let result;
@@ -687,9 +917,16 @@ async function pushChunked(client, ops, firstTxId, progress, opts) {
687
917
  progress.seq = result.seq;
688
918
  progress.idMap = { ...progress.idMap, ...result.idMap };
689
919
  progress.committedOps += chunk.length;
690
- if (result.warnings?.length) {
691
- for (const w of result.warnings)
692
- opts.report.notes.push(`server warning: ${w}`);
920
+ for (const d of result.dropped ?? []) {
921
+ const op = chunk[d.index];
922
+ if (!op) {
923
+ throw new Error(`server dropped op ${d.index} of a ${chunk.length}-op transaction — server/CLI contract drift`);
924
+ }
925
+ opts.report.dropped.push({ ...d, op });
926
+ }
927
+ for (const w of result.warnings ?? []) {
928
+ if (!opts.report.serverWarnings.includes(w))
929
+ opts.report.serverWarnings.push(w);
693
930
  }
694
931
  opts.onChunk?.(result.idMap);
695
932
  }