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.
- package/dist/api/objects.d.ts +3 -1
- package/dist/api/objects.js +24 -1
- package/dist/api/objects.js.map +1 -1
- package/dist/api/txPlan.d.ts +15 -0
- package/dist/api/txPlan.js +196 -0
- package/dist/api/txPlan.js.map +1 -0
- package/dist/codegen/emit.d.ts +2 -31
- package/dist/codegen/emit.js +7 -147
- package/dist/codegen/emit.js.map +1 -1
- package/dist/codegen/imports.d.ts +1 -17
- package/dist/codegen/imports.js +2 -116
- package/dist/codegen/imports.js.map +1 -1
- package/dist/codegen/instances.d.ts +2 -13
- package/dist/codegen/instances.js +23 -134
- package/dist/codegen/instances.js.map +1 -1
- package/dist/codegen/styles.d.ts +1 -11
- package/dist/codegen/styles.js +2 -130
- package/dist/codegen/styles.js.map +1 -1
- package/dist/merge/applyOps.d.ts +4 -1
- package/dist/merge/applyOps.js +42 -30
- package/dist/merge/applyOps.js.map +1 -1
- package/dist/merge/childrenOrder.d.ts +13 -0
- package/dist/merge/childrenOrder.js +51 -0
- package/dist/merge/childrenOrder.js.map +1 -0
- package/dist/merge/threeWay.d.ts +1 -1
- package/dist/merge/toEdits.d.ts +1 -3
- package/dist/merge/toEdits.js +3 -94
- package/dist/merge/toEdits.js.map +1 -1
- package/dist/project/baseStore.d.ts +11 -1
- package/dist/project/baseStore.js +13 -0
- package/dist/project/baseStore.js.map +1 -1
- package/dist/services/adoptService.js +11 -0
- package/dist/services/adoptService.js.map +1 -1
- package/dist/services/syncEngine.d.ts +1 -1
- package/dist/services/syncEngine.js +297 -60
- package/dist/services/syncEngine.js.map +1 -1
- package/dist/types.d.ts +28 -1
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -1
- package/dist/ui/report.d.ts +7 -0
- package/dist/ui/report.js +8 -0
- package/dist/ui/report.js.map +1 -1
- 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
|
-
|
|
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,
|
|
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(
|
|
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
|
|
116
|
-
|
|
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 (
|
|
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 ${
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
204
|
+
ops: resolveCids(allOps.slice(progress.committedOps), progress.idMap),
|
|
163
205
|
parkedAt: new Date().toISOString(),
|
|
164
206
|
});
|
|
165
|
-
report.pushedOps =
|
|
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: () =>
|
|
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: () =>
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
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
|
|
660
|
-
* the caller exactly what already landed
|
|
661
|
-
* (
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
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,
|
|
667
|
-
for (
|
|
668
|
-
const chunk = resolveCids(
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
}
|