mapples 0.2.0-beta.1 → 0.2.0-beta.3
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/README.md +70 -22
- package/dist/adopt/scan.d.ts +8 -2
- package/dist/adopt/scan.js +31 -13
- package/dist/adopt/scan.js.map +1 -1
- package/dist/api/client.d.ts +9 -1
- package/dist/api/client.js +21 -2
- package/dist/api/client.js.map +1 -1
- package/dist/api/commit.d.ts +9 -0
- package/dist/api/commit.js +32 -2
- package/dist/api/commit.js.map +1 -1
- package/dist/api/errors.js +8 -0
- package/dist/api/errors.js.map +1 -1
- package/dist/api/objects.d.ts +22 -5
- package/dist/api/objects.js +151 -10
- package/dist/api/objects.js.map +1 -1
- package/dist/api/poll.d.ts +5 -3
- package/dist/api/poll.js +17 -6
- package/dist/api/poll.js.map +1 -1
- package/dist/assets/assets.d.ts +9 -0
- package/dist/assets/assets.js +33 -4
- package/dist/assets/assets.js.map +1 -1
- package/dist/codegen/emit.d.ts +27 -1
- package/dist/codegen/emit.js +130 -9
- package/dist/codegen/emit.js.map +1 -1
- package/dist/codegen/imports.d.ts +2 -0
- package/dist/codegen/imports.js +38 -0
- package/dist/codegen/imports.js.map +1 -1
- package/dist/codegen/instances.d.ts +16 -0
- package/dist/codegen/instances.js +141 -0
- package/dist/codegen/instances.js.map +1 -0
- package/dist/codegen/styles.d.ts +11 -0
- package/dist/codegen/styles.js +131 -0
- package/dist/codegen/styles.js.map +1 -0
- package/dist/commands/impl/createImpl.js +8 -4
- package/dist/commands/impl/createImpl.js.map +1 -1
- package/dist/commands/impl/initImpl.js +5 -4
- package/dist/commands/impl/initImpl.js.map +1 -1
- package/dist/commands/impl/syncImpl.js +20 -2
- package/dist/commands/impl/syncImpl.js.map +1 -1
- package/dist/merge/applyOps.d.ts +1 -1
- package/dist/merge/applyOps.js +33 -2
- package/dist/merge/applyOps.js.map +1 -1
- package/dist/merge/diff.d.ts +1 -1
- package/dist/merge/diff.js +30 -17
- package/dist/merge/diff.js.map +1 -1
- package/dist/merge/threeWay.d.ts +1 -1
- package/dist/merge/threeWay.js +38 -6
- package/dist/merge/threeWay.js.map +1 -1
- package/dist/merge/toEdits.d.ts +1 -1
- package/dist/merge/toEdits.js +4 -1
- package/dist/merge/toEdits.js.map +1 -1
- package/dist/nav/navigatorTree.d.ts +78 -0
- package/dist/nav/navigatorTree.js +410 -0
- package/dist/nav/navigatorTree.js.map +1 -0
- package/dist/nav/placement.d.ts +53 -0
- package/dist/nav/placement.js +260 -0
- package/dist/nav/placement.js.map +1 -0
- package/dist/nav/routeTree.d.ts +8 -2
- package/dist/nav/routeTree.js +79 -151
- package/dist/nav/routeTree.js.map +1 -1
- package/dist/nav/screenOptions.d.ts +15 -0
- package/dist/nav/screenOptions.js +213 -0
- package/dist/nav/screenOptions.js.map +1 -0
- package/dist/project/baseStore.d.ts +8 -0
- package/dist/project/baseStore.js +15 -0
- package/dist/project/baseStore.js.map +1 -1
- package/dist/project/componentsMap.d.ts +7 -0
- package/dist/project/componentsMap.js +21 -0
- package/dist/project/componentsMap.js.map +1 -0
- package/dist/project/config.d.ts +12 -3
- package/dist/project/config.js +11 -2
- package/dist/project/config.js.map +1 -1
- package/dist/project/env.d.ts +10 -3
- package/dist/project/env.js +23 -5
- package/dist/project/env.js.map +1 -1
- package/dist/project/pagesMap.d.ts +2 -0
- package/dist/project/pagesMap.js.map +1 -1
- package/dist/services/adoptService.d.ts +3 -0
- package/dist/services/adoptService.js +346 -38
- package/dist/services/adoptService.js.map +1 -1
- package/dist/services/clientFactory.js +4 -2
- package/dist/services/clientFactory.js.map +1 -1
- package/dist/services/doctorService.js +77 -66
- package/dist/services/doctorService.js.map +1 -1
- package/dist/services/syncEngine.d.ts +7 -0
- package/dist/services/syncEngine.js +428 -292
- package/dist/services/syncEngine.js.map +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
- package/templates/app/_layout.tsx +3 -0
- package/templates/mapples/sid.d.ts +6 -0
|
@@ -1,32 +1,55 @@
|
|
|
1
1
|
// `mapples sync` end-to-end (RFC-0002 Phase 5): extract -> local ops vs base ->
|
|
2
|
-
// pull -> 3-way merge -> surgical file
|
|
3
|
-
// POST -> 409 rebase loop -> c_ -> nd_
|
|
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 {
|
|
13
|
-
import {
|
|
18
|
+
import { PRIMITIVE_MAPPING } from '../adopt/mappingTable.js';
|
|
19
|
+
import { emitComponent, emitPage } 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 {
|
|
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';
|
|
28
38
|
const MAX_REBASE_RETRIES = 5;
|
|
29
39
|
const MAX_OPS_PER_TX = 200;
|
|
40
|
+
// buildRemoteModel needs the project root to read the stored head; carried as
|
|
41
|
+
// a non-enumerable tag so BaseSnapshot's shape stays unchanged for callers.
|
|
42
|
+
const ROOT_TAG = Symbol('projectRoot');
|
|
43
|
+
function baseWithRoot(base, root) {
|
|
44
|
+
Object.defineProperty(base, ROOT_TAG, { value: root, configurable: true, enumerable: false });
|
|
45
|
+
return base;
|
|
46
|
+
}
|
|
47
|
+
function rootOf(base) {
|
|
48
|
+
const root = base[ROOT_TAG];
|
|
49
|
+
if (!root)
|
|
50
|
+
throw new Error('internal: base snapshot missing project root tag');
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
30
53
|
export async function runSyncEngine({ root, client }) {
|
|
31
54
|
const report = emptyReport();
|
|
32
55
|
const config = readConfig(root);
|
|
@@ -35,19 +58,32 @@ export async function runSyncEngine({ root, client }) {
|
|
|
35
58
|
const head = readHead(root);
|
|
36
59
|
// ---- Cold bootstrap ----
|
|
37
60
|
if (!head || !baseStore.exists()) {
|
|
38
|
-
return bootstrap(root, client, config
|
|
61
|
+
return bootstrap(root, client, config, report);
|
|
39
62
|
}
|
|
40
63
|
// ---- Drain outbox ----
|
|
41
64
|
let currentHead = head.head;
|
|
65
|
+
let currentSeq = head.seq;
|
|
42
66
|
for (const entry of outbox.list()) {
|
|
67
|
+
const progress = newProgress(currentHead, currentSeq);
|
|
43
68
|
try {
|
|
44
|
-
|
|
45
|
-
currentHead =
|
|
69
|
+
await pushChunked(client, entry.ops, entry.txId, progress, { rebaseInPlace: true, report });
|
|
70
|
+
currentHead = progress.head;
|
|
71
|
+
currentSeq = progress.seq;
|
|
46
72
|
outbox.clear(entry.txId);
|
|
47
73
|
report.notes.push(`drained outbox tx ${entry.txId} (${entry.ops.length} ops)`);
|
|
48
74
|
}
|
|
49
75
|
catch (err) {
|
|
50
76
|
if (isNetworkError(err)) {
|
|
77
|
+
if (progress.committedOps > 0) {
|
|
78
|
+
// Part of a split entry landed. Keep only what is left, with the
|
|
79
|
+
// committed cids resolved, under a fresh txId — the old one is spent.
|
|
80
|
+
outbox.clear(entry.txId);
|
|
81
|
+
outbox.park({
|
|
82
|
+
txId: mintTxId(),
|
|
83
|
+
ops: resolveCids(entry.ops.slice(progress.committedOps), progress.idMap),
|
|
84
|
+
parkedAt: entry.parkedAt,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
51
87
|
report.offline = true;
|
|
52
88
|
report.notes.push('still offline — outbox kept');
|
|
53
89
|
return report;
|
|
@@ -64,7 +100,7 @@ export async function runSyncEngine({ root, client }) {
|
|
|
64
100
|
const base = baseStore.readAll();
|
|
65
101
|
let remote;
|
|
66
102
|
try {
|
|
67
|
-
remote = await buildRemoteModel(client, base,
|
|
103
|
+
remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
|
|
68
104
|
}
|
|
69
105
|
catch (err) {
|
|
70
106
|
if (isNetworkError(err)) {
|
|
@@ -73,12 +109,12 @@ export async function runSyncEngine({ root, client }) {
|
|
|
73
109
|
throw err;
|
|
74
110
|
}
|
|
75
111
|
// ---- Merge + write + push (with 409 rebase loop) ----
|
|
76
|
-
|
|
112
|
+
let txId = mintTxId();
|
|
77
113
|
let attempt = 0;
|
|
78
114
|
for (;;) {
|
|
79
|
-
const { pushOps, mergedBase, pagesMap } = await mergeAndWrite(root, client, config, base, remote, report);
|
|
115
|
+
const { pushOps, mergedBase, pagesMap, componentsMap } = await mergeAndWrite(root, client, config, base, remote, report);
|
|
80
116
|
if (pushOps.length === 0) {
|
|
81
|
-
finalize(root, baseStore, mergedBase, pagesMap, remote.head, remote.seq);
|
|
117
|
+
finalize(root, baseStore, mergedBase, pagesMap, componentsMap, remote.head, remote.seq);
|
|
82
118
|
report.head = remote.head;
|
|
83
119
|
return report;
|
|
84
120
|
}
|
|
@@ -86,28 +122,19 @@ export async function runSyncEngine({ root, client }) {
|
|
|
86
122
|
// Split into sequential transactions (RFC-0001 §5.1 TX_TOO_LARGE discipline).
|
|
87
123
|
report.notes.push(`push split into ${Math.ceil(pushOps.length / MAX_OPS_PER_TX)} transactions`);
|
|
88
124
|
}
|
|
125
|
+
const progress = newProgress(remote.head, remote.seq);
|
|
89
126
|
try {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
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
|
+
});
|
|
107
135
|
report.pushedOps += pushOps.length;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
report.head = resultHead;
|
|
136
|
+
finalize(root, baseStore, mergedBase, pagesMap, componentsMap, progress.head, progress.seq);
|
|
137
|
+
report.head = progress.head;
|
|
111
138
|
return report;
|
|
112
139
|
}
|
|
113
140
|
catch (err) {
|
|
@@ -116,18 +143,28 @@ export async function runSyncEngine({ root, client }) {
|
|
|
116
143
|
if (attempt >= MAX_REBASE_RETRIES) {
|
|
117
144
|
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
145
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
146
|
+
if (progress.committedOps > 0) {
|
|
147
|
+
// Earlier chunks landed under `txId`; replaying it would hand back
|
|
148
|
+
// their result instead of committing the re-merged ops.
|
|
149
|
+
report.pushedOps += progress.committedOps;
|
|
150
|
+
txId = mintTxId();
|
|
151
|
+
}
|
|
152
|
+
report.notes.push(`rebase ${attempt}: HEAD moved to ${err.head} — refetching state`);
|
|
153
|
+
remote = await buildRemoteModel(client, baseWithRoot(base, root), currentSeq, report);
|
|
154
|
+
continue; // re-merge against the advanced remote, retry the same txId unless it is spent
|
|
122
155
|
}
|
|
123
156
|
if (isNetworkError(err)) {
|
|
124
|
-
// Files already show the merged view; park
|
|
125
|
-
//
|
|
126
|
-
|
|
157
|
+
// Files already show the merged view; park what did not land (with
|
|
158
|
+
// the committed chunks' cids resolved) and adopt the remote head so
|
|
159
|
+
// the drain pushes exactly these ops next time.
|
|
160
|
+
outbox.park({
|
|
161
|
+
txId: progress.committedOps > 0 ? mintTxId() : txId,
|
|
162
|
+
ops: resolveCids(pushOps.slice(progress.committedOps), progress.idMap),
|
|
163
|
+
parkedAt: new Date().toISOString(),
|
|
164
|
+
});
|
|
127
165
|
report.pushedOps = pushOps.length;
|
|
128
166
|
report.offline = true;
|
|
129
|
-
|
|
130
|
-
finalize(root, baseStore, remoteOnlyBase, { ...readPagesMap(root), ...pagesMap }, remote.head, remote.seq);
|
|
167
|
+
finalize(root, baseStore, remoteAsBase(remote), readPagesMap(root), readComponentsMap(root), remote.head, remote.seq);
|
|
131
168
|
return report;
|
|
132
169
|
}
|
|
133
170
|
throw err;
|
|
@@ -135,40 +172,52 @@ export async function runSyncEngine({ root, client }) {
|
|
|
135
172
|
}
|
|
136
173
|
}
|
|
137
174
|
// ---------------------------------------------------------------- bootstrap
|
|
138
|
-
async function bootstrap(root, client,
|
|
175
|
+
async function bootstrap(root, client, config, report) {
|
|
139
176
|
const boot = await client.bootstrap();
|
|
140
177
|
const state = await fetchProjectState(client, boot);
|
|
141
178
|
const baseStore = new BaseStore(root);
|
|
142
179
|
const pagesMap = readPagesMap(root);
|
|
143
|
-
|
|
180
|
+
// Placement first: every page gets its file position from the navigation.
|
|
181
|
+
const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, state.pages, pagesMap, state.navigation, state.navigation, report);
|
|
182
|
+
const componentsMap = readComponentsMap(root);
|
|
183
|
+
placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, state.components, componentsMap);
|
|
184
|
+
const linker = instanceLinker(componentsMap, state.components);
|
|
185
|
+
const targets = [];
|
|
144
186
|
for (const page of state.pages.values()) {
|
|
145
|
-
|
|
146
|
-
if (!mapping)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
const mapping = pagesMap[page.id];
|
|
188
|
+
if (!mapping)
|
|
189
|
+
throw new Error(`internal: page ${page.id} was not placed`);
|
|
190
|
+
targets.push({
|
|
191
|
+
file: mapping.file,
|
|
192
|
+
emit: () => emitPage(emitInput(page.name, linker.unlink(page.root, mapping.file, warnInto(report, mapping.file)), mapping), join(root, mapping.file)),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
for (const component of state.components.values()) {
|
|
196
|
+
const mapping = componentsMap[component.id];
|
|
197
|
+
targets.push({
|
|
198
|
+
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)),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
const filePath = join(root, target.file);
|
|
156
207
|
if (existsSync(filePath)) {
|
|
157
208
|
// P0: never overwrite. The file's content will be merged on the next sync.
|
|
158
209
|
report.files.push({
|
|
159
|
-
file:
|
|
210
|
+
file: target.file,
|
|
160
211
|
action: 'unchanged',
|
|
161
212
|
detail: 'exists — left untouched',
|
|
162
213
|
});
|
|
163
214
|
continue;
|
|
164
215
|
}
|
|
165
216
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
166
|
-
writeFileSync(filePath, await
|
|
167
|
-
report.files.push({ file:
|
|
217
|
+
writeFileSync(filePath, await target.emit());
|
|
218
|
+
report.files.push({ file: target.file, action: 'created' });
|
|
168
219
|
}
|
|
169
|
-
const config = readConfig(root);
|
|
170
220
|
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
221
|
const assetsResult = await syncAssets(root, client, state.assets, report);
|
|
173
222
|
if (assetsResult.pushOps.length > 0) {
|
|
174
223
|
report.notes.push('local assets found — run `mapples sync` again to upload them');
|
|
@@ -180,165 +229,89 @@ async function bootstrap(root, client, appDir, report) {
|
|
|
180
229
|
style: styleResult.mergedStyle,
|
|
181
230
|
assets: state.assets,
|
|
182
231
|
});
|
|
232
|
+
baseStore.writeStamp(state.head);
|
|
183
233
|
writePagesMap(root, pagesMap);
|
|
234
|
+
writeComponentsMap(root, componentsMap);
|
|
184
235
|
writeHead(root, { head: state.head, seq: state.seq });
|
|
185
236
|
report.head = state.head;
|
|
186
|
-
report.notes.push(`bootstrapped ${state.pages.size} page(s) @ ${state.head}`);
|
|
237
|
+
report.notes.push(`bootstrapped ${state.pages.size} page(s), ${state.components.size} component(s) @ ${state.head}`);
|
|
187
238
|
return report;
|
|
188
239
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
240
|
+
/** Every Creator component gets a file under componentsDir; existing mappings are kept. */
|
|
241
|
+
function placeComponents(componentsDir, components, componentsMap) {
|
|
242
|
+
const used = new Set(Object.values(componentsMap).map((m) => m.file));
|
|
243
|
+
for (const [id, component] of components) {
|
|
244
|
+
if (componentsMap[id])
|
|
245
|
+
continue;
|
|
246
|
+
const file = mintEntityFile(componentsDir, component.name, used);
|
|
247
|
+
used.add(file);
|
|
248
|
+
componentsMap[id] = { file, name: component.name };
|
|
196
249
|
}
|
|
197
|
-
|
|
250
|
+
}
|
|
251
|
+
/** The route marker for a routed page's (re)generated file. */
|
|
252
|
+
function emitInput(name, root, mapping) {
|
|
253
|
+
return mapping.routeId ? { name, root, route: { id: mapping.routeId, name } } : { name, root };
|
|
198
254
|
}
|
|
199
255
|
// ------------------------------------------------------------- remote model
|
|
200
|
-
async function buildRemoteModel(client, base,
|
|
256
|
+
async function buildRemoteModel(client, base, sinceSeq, report) {
|
|
257
|
+
// The delta is used only to detect change and report op counts. The remote
|
|
258
|
+
// MODEL always comes from a full snapshot fetch: the real backend currently
|
|
259
|
+
// moves HEAD through legacy-write mirroring without emitting the matching
|
|
260
|
+
// ops (navigation saves, page creation — RFC-0001 invariant 10 violation),
|
|
261
|
+
// so op-replay would silently reconstruct a stale tree. Revisit when the
|
|
262
|
+
// backend guarantees every HEAD move carries its ops.
|
|
263
|
+
let head;
|
|
201
264
|
try {
|
|
202
|
-
const delta = await pullDelta(client,
|
|
265
|
+
const delta = await pullDelta(client, sinceSeq);
|
|
203
266
|
report.pulledOps = delta.ops.length;
|
|
204
|
-
|
|
267
|
+
head = delta.head;
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
if (!(err instanceof SnapshotRequired))
|
|
271
|
+
throw err;
|
|
272
|
+
report.notes.push('too far behind — cold re-bootstrap of remote state');
|
|
273
|
+
head = '';
|
|
274
|
+
}
|
|
275
|
+
const storedHead = readHead(rootOf(base)) ?? null;
|
|
276
|
+
const stamped = new BaseStore(rootOf(base)).readStamp();
|
|
277
|
+
if (head !== '' &&
|
|
278
|
+
storedHead &&
|
|
279
|
+
head === storedHead.head &&
|
|
280
|
+
stamped === storedHead.head &&
|
|
281
|
+
report.pulledOps === 0) {
|
|
282
|
+
// Nothing changed server-side and the base was written at this very
|
|
283
|
+
// head: the base IS the remote state.
|
|
284
|
+
return {
|
|
205
285
|
pages: structuredClone(base.pages),
|
|
286
|
+
components: structuredClone(base.components),
|
|
206
287
|
navigation: structuredClone(base.navigation),
|
|
207
288
|
style: structuredClone(base.style),
|
|
208
289
|
assets: structuredClone(base.assets),
|
|
209
|
-
head:
|
|
210
|
-
seq:
|
|
290
|
+
head: storedHead.head,
|
|
291
|
+
seq: storedHead.seq,
|
|
211
292
|
};
|
|
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
|
-
}
|
|
231
|
-
}
|
|
232
|
-
function applyDeltaToRemote(model, ops, head, seq, base, report) {
|
|
233
|
-
model.head = head;
|
|
234
|
-
model.seq = seq;
|
|
235
|
-
const nodeOps = ops.filter((o) => o.entity === 'RenderNode');
|
|
236
|
-
for (const page of model.pages.values()) {
|
|
237
|
-
const { root } = applyNodeOps(page.root, nodeOps);
|
|
238
|
-
if (root)
|
|
239
|
-
page.root = root;
|
|
240
|
-
else
|
|
241
|
-
model.pages.delete(page.id);
|
|
242
|
-
}
|
|
243
|
-
for (const op of ops) {
|
|
244
|
-
switch (op.entity) {
|
|
245
|
-
case 'RenderNode':
|
|
246
|
-
break; // handled above
|
|
247
|
-
case 'Page': {
|
|
248
|
-
if (op.op === 'create') {
|
|
249
|
-
const data = op.data;
|
|
250
|
-
const id = op.id ?? op.cid;
|
|
251
|
-
if (!id)
|
|
252
|
-
throw new Error('Page create op without id');
|
|
253
|
-
const tree = buildTreeFromOps(nodeOps, data.root);
|
|
254
|
-
if (!tree) {
|
|
255
|
-
report.notes.push(`pulled Page ${id} but its root ${data.root} is not in the delta — skipping`);
|
|
256
|
-
break;
|
|
257
|
-
}
|
|
258
|
-
model.pages.set(id, { id, name: data.name, route: data.route, root: tree });
|
|
259
|
-
}
|
|
260
|
-
else if (op.op === 'update' && op.id) {
|
|
261
|
-
const page = model.pages.get(op.id);
|
|
262
|
-
if (page && op.patch) {
|
|
263
|
-
const patched = applyMergePatch({ name: page.name, route: page.route }, op.patch);
|
|
264
|
-
page.name = patched.name;
|
|
265
|
-
page.route = patched.route;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
else if (op.op === 'delete' && op.id) {
|
|
269
|
-
model.pages.delete(op.id);
|
|
270
|
-
}
|
|
271
|
-
break;
|
|
272
|
-
}
|
|
273
|
-
case 'NavigationRoute': {
|
|
274
|
-
const nav = model.navigation ?? { routes: [], layouts: {} };
|
|
275
|
-
if (op.op === 'create') {
|
|
276
|
-
const id = op.id ?? op.cid;
|
|
277
|
-
if (id)
|
|
278
|
-
nav.routes.push({ id, ...op.data });
|
|
279
|
-
}
|
|
280
|
-
else if (op.op === 'update' && op.id && op.patch) {
|
|
281
|
-
const idx = nav.routes.findIndex((r) => r.id === op.id);
|
|
282
|
-
if (idx !== -1)
|
|
283
|
-
nav.routes[idx] = applyMergePatch(nav.routes[idx], op.patch);
|
|
284
|
-
}
|
|
285
|
-
else if (op.op === 'delete' && op.id) {
|
|
286
|
-
nav.routes = nav.routes.filter((r) => r.id !== op.id);
|
|
287
|
-
}
|
|
288
|
-
model.navigation = nav;
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
case 'NavigationDocument':
|
|
292
|
-
if (op.patch)
|
|
293
|
-
model.navigation = applyMergePatch(model.navigation ?? { routes: [], layouts: {} }, op.patch);
|
|
294
|
-
break;
|
|
295
|
-
case 'StyleConfig':
|
|
296
|
-
if (op.patch)
|
|
297
|
-
model.style = applyMergePatch(model.style ?? { theme: {}, typography: {}, sizing: {} }, op.patch);
|
|
298
|
-
break;
|
|
299
|
-
case 'Theme':
|
|
300
|
-
case 'Typography':
|
|
301
|
-
case 'Sizing': {
|
|
302
|
-
const key = op.entity.toLowerCase();
|
|
303
|
-
const style = model.style ?? { theme: {}, typography: {}, sizing: {} };
|
|
304
|
-
if (op.patch) {
|
|
305
|
-
model.style = { ...style, [key]: applyMergePatch(style[key], op.patch) };
|
|
306
|
-
}
|
|
307
|
-
break;
|
|
308
|
-
}
|
|
309
|
-
case 'Asset': {
|
|
310
|
-
const assets = model.assets ?? {};
|
|
311
|
-
const id = op.id ?? op.cid;
|
|
312
|
-
if (op.op === 'delete' && op.id)
|
|
313
|
-
delete assets[op.id];
|
|
314
|
-
else if (op.op === 'create' && id)
|
|
315
|
-
assets[id] = op.data;
|
|
316
|
-
else if (op.op === 'update' && op.id && op.patch) {
|
|
317
|
-
assets[op.id] = applyMergePatch(assets[op.id], op.patch);
|
|
318
|
-
}
|
|
319
|
-
model.assets = assets;
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
default:
|
|
323
|
-
report.notes.push(`pulled op for unhandled entity ${op.entity} — recorded in base only`);
|
|
324
|
-
}
|
|
325
293
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
294
|
+
const boot = await client.bootstrap();
|
|
295
|
+
const state = await fetchProjectState(client, boot);
|
|
296
|
+
return {
|
|
297
|
+
pages: state.pages,
|
|
298
|
+
components: state.components,
|
|
299
|
+
navigation: state.navigation,
|
|
300
|
+
style: state.style,
|
|
301
|
+
assets: state.assets,
|
|
302
|
+
head: state.head,
|
|
303
|
+
seq: state.seq,
|
|
304
|
+
};
|
|
333
305
|
}
|
|
306
|
+
const PAGE_KIND = { label: 'page', entity: 'Page' };
|
|
307
|
+
const COMPONENT_KIND = { label: 'component', entity: 'Component' };
|
|
334
308
|
async function mergeAndWrite(root, client, config, base, remote, report) {
|
|
335
|
-
const appDir = config.appDir;
|
|
336
309
|
const pagesMap = readPagesMap(root);
|
|
337
|
-
const
|
|
310
|
+
const componentsMap = readComponentsMap(root);
|
|
338
311
|
const pushOps = [];
|
|
339
312
|
const mergedBase = {
|
|
340
313
|
pages: new Map(),
|
|
341
|
-
components:
|
|
314
|
+
components: new Map(),
|
|
342
315
|
navigation: remote.navigation,
|
|
343
316
|
style: remote.style,
|
|
344
317
|
assets: remote.assets,
|
|
@@ -347,43 +320,137 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
|
|
|
347
320
|
report.files = [];
|
|
348
321
|
report.conflicts = [];
|
|
349
322
|
report.warnings = [];
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
323
|
+
// ---- Navigation first: it decides where every page file lives ----
|
|
324
|
+
const navResult = syncNavigation(root, config.routesDir ?? DEFAULT_ROUTES_DIR, config.pagesDir, remote.pages, pagesMap, base.navigation, remote.navigation, report);
|
|
325
|
+
pushOps.push(...navResult.pushOps);
|
|
326
|
+
mergedBase.navigation = navResult.mergedNavigation;
|
|
327
|
+
placeComponents(config.componentsDir ?? DEFAULT_COMPONENTS_DIR, remote.components, componentsMap);
|
|
328
|
+
// Component uuids come from the remote model; a page's instances link to them.
|
|
329
|
+
const linker = instanceLinker(componentsMap, remote.components);
|
|
330
|
+
await mergeFiles({
|
|
331
|
+
root,
|
|
332
|
+
kind: PAGE_KIND,
|
|
333
|
+
base: base.pages,
|
|
334
|
+
remote: remote.pages,
|
|
335
|
+
mapping: pagesMap,
|
|
336
|
+
linker,
|
|
337
|
+
emit: (entity, mapping, tree, filePath) => emitPage(emitInput(entity.name, tree, mapping), filePath),
|
|
338
|
+
mergedOut: mergedBase.pages,
|
|
339
|
+
pushOps,
|
|
340
|
+
report,
|
|
341
|
+
});
|
|
342
|
+
await mergeFiles({
|
|
343
|
+
root,
|
|
344
|
+
kind: COMPONENT_KIND,
|
|
345
|
+
base: base.components,
|
|
346
|
+
remote: remote.components,
|
|
347
|
+
mapping: componentsMap,
|
|
348
|
+
linker,
|
|
349
|
+
emit: (entity, _mapping, tree, filePath) => emitComponent({ name: entity.name, root: tree }, filePath),
|
|
350
|
+
mergedOut: mergedBase.components,
|
|
351
|
+
pushOps,
|
|
352
|
+
report,
|
|
353
|
+
});
|
|
354
|
+
// ---- Non-page entities: style, assets ----
|
|
355
|
+
const styleResult = syncStyleFile(root, base.style, remote.style, report);
|
|
356
|
+
pushOps.push(...styleResult.pushOps);
|
|
357
|
+
mergedBase.style = styleResult.mergedStyle;
|
|
358
|
+
const assetsResult = await syncAssets(root, client, remote.assets, report);
|
|
359
|
+
pushOps.push(...assetsResult.pushOps);
|
|
360
|
+
mergedBase.assets = assetsResult.mergedAssets;
|
|
361
|
+
return { pushOps, mergedBase, pagesMap, componentsMap };
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The 3-way file merge for one entity kind: extract the file, merge against
|
|
365
|
+
* base and remote, write the merged view (patch in place, or regenerate),
|
|
366
|
+
* deprecate on conflict, and collect the ops that push local changes.
|
|
367
|
+
*/
|
|
368
|
+
async function mergeFiles(args) {
|
|
369
|
+
const { root, kind, base, remote, mapping: mappings, linker, emit, mergedOut, pushOps, report, } = args;
|
|
370
|
+
const ids = new Set([...base.keys(), ...remote.keys()]);
|
|
371
|
+
for (const id of ids) {
|
|
372
|
+
const baseEntity = base.get(id) ?? null;
|
|
373
|
+
const remoteEntity = remote.get(id) ?? null;
|
|
374
|
+
const mapping = mappings[id];
|
|
355
375
|
if (!mapping) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
route: remotePage?.route ?? basePage?.route ?? '/',
|
|
361
|
-
};
|
|
362
|
-
pagesMap[pageId] = mapping;
|
|
363
|
-
usedFiles.add(mapping.file);
|
|
376
|
+
// Known to the base only and never written to disk: nothing to reconcile.
|
|
377
|
+
if (remoteEntity)
|
|
378
|
+
throw new Error(`internal: ${kind.label} ${id} was not placed`);
|
|
379
|
+
continue;
|
|
364
380
|
}
|
|
365
381
|
const filePath = join(root, mapping.file);
|
|
366
382
|
const fileExists = existsSync(filePath);
|
|
367
383
|
// local = fresh extract, with NON_LITERAL props refilled from base.
|
|
368
384
|
let local;
|
|
385
|
+
// The same tree as the file reads it (linked instances still Customs):
|
|
386
|
+
// what edits to the file are computed against.
|
|
387
|
+
let localFile = null;
|
|
369
388
|
let fileSource = null;
|
|
370
389
|
if (fileExists) {
|
|
371
390
|
fileSource = readFileSync(filePath, 'utf8');
|
|
391
|
+
// Pre-extraction import healing: a file left behind by an interrupted
|
|
392
|
+
// sync may use elements its import block never gained. Without the
|
|
393
|
+
// import, extraction reads primitives as local Customs and the merge
|
|
394
|
+
// would push that misreading to the server. The BASE tree knows the true
|
|
395
|
+
// types for every existing sid.
|
|
396
|
+
if (baseEntity) {
|
|
397
|
+
const healed = upsertImports(fileSource, importNeedsFor(linker.unlink(baseEntity.root, mapping.file, () => undefined)));
|
|
398
|
+
if (healed !== fileSource) {
|
|
399
|
+
fileSource = healed;
|
|
400
|
+
writeFileSync(filePath, healed);
|
|
401
|
+
report.notes.push(`${mapping.file}: restored missing imports before extraction`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// An element the developer added without a `$sid` is invisible to
|
|
405
|
+
// extraction — and so to the Creator — for as long as it stays untagged.
|
|
406
|
+
// Tag it the way adoption did the file: react-native primitives swapped
|
|
407
|
+
// and their props translated, anything else an opaque Custom node.
|
|
408
|
+
const tagged = codemodAdopt(fileSource, {
|
|
409
|
+
mapping: PRIMITIVE_MAPPING,
|
|
410
|
+
mintSid: mintCid,
|
|
411
|
+
isLinked: (origin) => linker.isLinked(origin, mapping.file),
|
|
412
|
+
});
|
|
413
|
+
if (tagged.adopted.length > 0) {
|
|
414
|
+
fileSource = tagged.source;
|
|
415
|
+
writeFileSync(filePath, fileSource);
|
|
416
|
+
const names = tagged.adopted.map((a) => a.kind === 'custom' ? `${a.originalName} (Custom)` : a.originalName);
|
|
417
|
+
report.notes.push(`${mapping.file}: tagged ${tagged.adopted.length} new element(s) — ${names.join(', ')}`);
|
|
418
|
+
for (const skip of tagged.skipped) {
|
|
419
|
+
report.warnings.push({
|
|
420
|
+
kind: 'UNKNOWN_ELEMENT_WITH_SID',
|
|
421
|
+
detail: `${mapping.file}: ${skip.reason}`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
372
425
|
const extracted = extract(fileSource);
|
|
373
426
|
for (const w of extracted.warnings)
|
|
374
427
|
report.warnings.push({ ...w, file: mapping.file });
|
|
375
428
|
local = extracted.dom;
|
|
376
|
-
if (local
|
|
377
|
-
|
|
429
|
+
if (local) {
|
|
430
|
+
// A hand-written `$sid` that is neither a server id nor a ULID-shaped
|
|
431
|
+
// `c_` placeholder fails the server's envelope validation and takes
|
|
432
|
+
// the whole push down with it. Mint the placeholder here instead.
|
|
433
|
+
const renamed = normalizeClientIds(local);
|
|
434
|
+
if (renamed.size > 0) {
|
|
435
|
+
for (const [from, to] of renamed)
|
|
436
|
+
fileSource = fileSource.split(`"${from}"`).join(`"${to}"`);
|
|
437
|
+
writeFileSync(filePath, fileSource);
|
|
438
|
+
report.notes.push(`${mapping.file}: minted client id(s) for ${[...renamed.keys()].join(', ')} (a new element's $sid must be c_<ULID>)`);
|
|
439
|
+
}
|
|
440
|
+
localFile = structuredClone(local);
|
|
441
|
+
linker.link(local, mapping.file, warnInto(report, mapping.file));
|
|
442
|
+
}
|
|
443
|
+
if (local && baseEntity)
|
|
444
|
+
fillNonLiteralProps(local, baseEntity.root, extracted.warnings);
|
|
378
445
|
}
|
|
379
446
|
else {
|
|
380
|
-
local =
|
|
381
|
-
if (
|
|
447
|
+
local = baseEntity?.root ?? null; // no file -> treat as unchanged
|
|
448
|
+
if (baseEntity) {
|
|
382
449
|
report.notes.push(`${mapping.file} is missing — regenerating from the merged canvas state`);
|
|
383
450
|
}
|
|
384
451
|
}
|
|
385
|
-
const baseRoot =
|
|
386
|
-
const remoteRoot =
|
|
452
|
+
const baseRoot = baseEntity?.root ?? null;
|
|
453
|
+
const remoteRoot = remoteEntity?.root ?? null;
|
|
387
454
|
const { merged, conflicts } = threeWayMerge(baseRoot, local, remoteRoot);
|
|
388
455
|
if (conflicts.length > 0) {
|
|
389
456
|
let deprecatedTo;
|
|
@@ -391,58 +458,66 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
|
|
|
391
458
|
deprecatedTo = relative(root, deprecateFile(filePath));
|
|
392
459
|
for (const c of conflicts)
|
|
393
460
|
report.conflicts.push({ ...c, file: mapping.file, deprecatedTo });
|
|
394
|
-
if (
|
|
461
|
+
if (remoteEntity) {
|
|
395
462
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
396
|
-
writeFileSync(filePath, await
|
|
463
|
+
writeFileSync(filePath, await emit(remoteEntity, mapping, linker.unlink(remoteEntity.root, mapping.file, warnInto(report, mapping.file)), filePath));
|
|
397
464
|
report.files.push({
|
|
398
465
|
file: mapping.file,
|
|
399
466
|
action: 'deprecated',
|
|
400
467
|
detail: `conflict — your version kept at ${deprecatedTo ?? '(no file)'}; regenerated from Creator`,
|
|
401
468
|
});
|
|
402
|
-
|
|
403
|
-
mapping.name =
|
|
404
|
-
mapping.route = remotePage.route;
|
|
469
|
+
mergedOut.set(id, remoteEntity);
|
|
470
|
+
mapping.name = remoteEntity.name;
|
|
405
471
|
}
|
|
406
472
|
else {
|
|
407
473
|
report.files.push({
|
|
408
474
|
file: mapping.file,
|
|
409
475
|
action: 'deprecated',
|
|
410
|
-
detail:
|
|
476
|
+
detail: `${kind.label} deleted in Creator; your local edits kept in the .old file`,
|
|
411
477
|
});
|
|
412
|
-
delete
|
|
478
|
+
delete mappings[id];
|
|
413
479
|
}
|
|
414
480
|
continue; // keepLocal ops for this file are DROPPED from the push
|
|
415
481
|
}
|
|
416
|
-
if (
|
|
482
|
+
if (remoteEntity === null) {
|
|
417
483
|
// Deleted remotely, no local edits (else it would have conflicted).
|
|
418
484
|
if (fileExists) {
|
|
419
485
|
const deprecatedTo = relative(root, deprecateFile(filePath));
|
|
420
486
|
report.files.push({
|
|
421
487
|
file: mapping.file,
|
|
422
488
|
action: 'deprecated',
|
|
423
|
-
detail:
|
|
489
|
+
detail: `${kind.label} deleted in Creator — file kept at ${deprecatedTo}`,
|
|
424
490
|
});
|
|
425
491
|
}
|
|
426
|
-
delete
|
|
492
|
+
delete mappings[id];
|
|
427
493
|
continue;
|
|
428
494
|
}
|
|
429
495
|
if (merged === null) {
|
|
430
|
-
// Locally deleted
|
|
431
|
-
pushOps.push({
|
|
432
|
-
|
|
496
|
+
// Locally deleted tree, remote unchanged: push the deletion.
|
|
497
|
+
pushOps.push({
|
|
498
|
+
op: 'delete',
|
|
499
|
+
entity: 'RenderNode',
|
|
500
|
+
id: remoteEntity.root.sid,
|
|
501
|
+
cascade: true,
|
|
502
|
+
});
|
|
503
|
+
pushOps.push({ op: 'delete', entity: kind.entity, id });
|
|
433
504
|
report.files.push({
|
|
434
505
|
file: mapping.file,
|
|
435
506
|
action: 'updated',
|
|
436
507
|
detail: 'local deletion pushed to Creator',
|
|
437
508
|
});
|
|
438
|
-
delete
|
|
509
|
+
delete mappings[id];
|
|
439
510
|
continue;
|
|
440
511
|
}
|
|
441
|
-
// Write the merged view into the file.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
512
|
+
// Write the merged view into the file — in the file's language.
|
|
513
|
+
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);
|
|
446
521
|
for (const w of warnings)
|
|
447
522
|
report.warnings.push({ ...w, file: mapping.file });
|
|
448
523
|
writeFileSync(filePath, patched);
|
|
@@ -454,26 +529,20 @@ async function mergeAndWrite(root, client, config, base, remote, report) {
|
|
|
454
529
|
}
|
|
455
530
|
else {
|
|
456
531
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
457
|
-
writeFileSync(filePath, await
|
|
532
|
+
writeFileSync(filePath, await emit(remoteEntity, mapping, mergedFile, filePath));
|
|
458
533
|
report.files.push({ file: mapping.file, action: fileExists ? 'regenerated' : 'created' });
|
|
459
534
|
}
|
|
460
535
|
// Push = local changes rebased onto remote.
|
|
461
536
|
pushOps.push(...diffTree(remoteRoot, merged));
|
|
462
|
-
|
|
463
|
-
mapping.name =
|
|
464
|
-
mapping.route = remotePage.route;
|
|
537
|
+
mergedOut.set(id, { ...remoteEntity, root: merged });
|
|
538
|
+
mapping.name = remoteEntity.name;
|
|
465
539
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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 };
|
|
540
|
+
}
|
|
541
|
+
/** A warning sink that lands each warning in the report stamped with its file. */
|
|
542
|
+
function warnInto(report, file) {
|
|
543
|
+
return (w) => {
|
|
544
|
+
report.warnings.push({ ...w, file });
|
|
545
|
+
};
|
|
477
546
|
}
|
|
478
547
|
/** NON_LITERAL_PROP contract: expression props keep their last-known base value. */
|
|
479
548
|
function fillNonLiteralProps(local, baseRoot, warnings) {
|
|
@@ -501,13 +570,35 @@ function fillNonLiteralProps(local, baseRoot, warnings) {
|
|
|
501
570
|
}
|
|
502
571
|
}
|
|
503
572
|
}
|
|
573
|
+
// ------------------------------------------------------------ client ids
|
|
574
|
+
/** Ids the server accepts as-is (RFC-0001 §5): a DB id, or a ULID-shaped `c_` placeholder. */
|
|
575
|
+
const SERVER_ID_RE = /^(nd|pg|cmp|rt|st|th|ty|sz|as|nav)_\w+$/;
|
|
576
|
+
const CLIENT_ID_RE = /^c_[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
577
|
+
/**
|
|
578
|
+
* Replace every sid the server would reject with a freshly minted `c_` id,
|
|
579
|
+
* in place. Returns old -> new so the caller can rewrite the file the same
|
|
580
|
+
* way `rewriteIds` does after a push.
|
|
581
|
+
*/
|
|
582
|
+
export function normalizeClientIds(root) {
|
|
583
|
+
const renamed = new Map();
|
|
584
|
+
const walk = (node) => {
|
|
585
|
+
if (!SERVER_ID_RE.test(node.sid) && !CLIENT_ID_RE.test(node.sid)) {
|
|
586
|
+
const fresh = mintCid();
|
|
587
|
+
renamed.set(node.sid, fresh);
|
|
588
|
+
node.sid = fresh;
|
|
589
|
+
}
|
|
590
|
+
node.children.forEach(walk);
|
|
591
|
+
};
|
|
592
|
+
walk(root);
|
|
593
|
+
return renamed;
|
|
594
|
+
}
|
|
504
595
|
// ------------------------------------------------------------ idMap rewrite
|
|
505
596
|
/** After a successful push, rewrite client-minted c_ sids to server nd_ ids in files and base. */
|
|
506
|
-
function rewriteIds(root, mergedBase,
|
|
597
|
+
function rewriteIds(root, mergedBase, fileMaps, idMap, report) {
|
|
507
598
|
const entries = Object.entries(idMap).filter(([cid]) => cid.startsWith('c_'));
|
|
508
599
|
if (entries.length === 0)
|
|
509
600
|
return;
|
|
510
|
-
for (const mapping of Object.values(
|
|
601
|
+
for (const mapping of fileMaps.flatMap((m) => Object.values(m))) {
|
|
511
602
|
const filePath = join(root, mapping.file);
|
|
512
603
|
if (!existsSync(filePath))
|
|
513
604
|
continue;
|
|
@@ -530,6 +621,8 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
|
|
|
530
621
|
};
|
|
531
622
|
for (const page of mergedBase.pages.values())
|
|
532
623
|
renameTree(page.root);
|
|
624
|
+
for (const component of mergedBase.components.values())
|
|
625
|
+
renameTree(component.root);
|
|
533
626
|
if (mergedBase.assets) {
|
|
534
627
|
mergedBase.assets = resolveAssetIds(mergedBase.assets, idMap);
|
|
535
628
|
writeAssetMap(root, mergedBase.assets);
|
|
@@ -537,58 +630,85 @@ function rewriteIds(root, mergedBase, pagesMap, idMap, report) {
|
|
|
537
630
|
report.notes.push(`resolved ${entries.length} new node id(s) from the server`);
|
|
538
631
|
}
|
|
539
632
|
// ----------------------------------------------------------------- finalize
|
|
540
|
-
function remoteAsBase(
|
|
633
|
+
function remoteAsBase(remote) {
|
|
541
634
|
return {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
assets: remote.assets,
|
|
548
|
-
},
|
|
549
|
-
pagesMap: {},
|
|
635
|
+
pages: remote.pages,
|
|
636
|
+
components: remote.components,
|
|
637
|
+
navigation: remote.navigation,
|
|
638
|
+
style: remote.style,
|
|
639
|
+
assets: remote.assets,
|
|
550
640
|
};
|
|
551
641
|
}
|
|
552
|
-
function finalize(root, baseStore, mergedBase, pagesMap, head, seq) {
|
|
642
|
+
function finalize(root, baseStore, mergedBase, pagesMap, componentsMap, head, seq) {
|
|
553
643
|
baseStore.writeAll(mergedBase);
|
|
644
|
+
baseStore.writeStamp(head);
|
|
554
645
|
writePagesMap(root, pagesMap);
|
|
646
|
+
writeComponentsMap(root, componentsMap);
|
|
555
647
|
writeHead(root, { head, seq });
|
|
556
648
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
649
|
+
function newProgress(head, seq) {
|
|
650
|
+
return { head, seq, idMap: {}, committedOps: 0 };
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
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
|
|
657
|
+
* so far before it is sent — otherwise the server answers CID_UNRESOLVED.
|
|
658
|
+
*
|
|
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.
|
|
665
|
+
*/
|
|
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);
|
|
669
|
+
const txId = i === 0 ? firstTxId : mintTxId();
|
|
670
|
+
let cursor = progress.head;
|
|
671
|
+
let result;
|
|
672
|
+
for (let attempt = 0; result === undefined; attempt++) {
|
|
673
|
+
try {
|
|
674
|
+
result = await commitWithTransportRetry(client, buildEnvelope(client, { base: cursor, ops: chunk, txId }));
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
if (!opts.rebaseInPlace || !(err instanceof HeadMismatchError))
|
|
678
|
+
throw err;
|
|
679
|
+
if (attempt + 1 >= MAX_REBASE_RETRIES) {
|
|
680
|
+
throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
|
|
681
|
+
}
|
|
566
682
|
cursor = err.head;
|
|
567
|
-
report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
|
|
568
|
-
continue;
|
|
683
|
+
opts.report.notes.push(`outbox rebase: HEAD moved to ${err.head}`);
|
|
569
684
|
}
|
|
570
|
-
throw err;
|
|
571
685
|
}
|
|
686
|
+
progress.head = result.head;
|
|
687
|
+
progress.seq = result.seq;
|
|
688
|
+
progress.idMap = { ...progress.idMap, ...result.idMap };
|
|
689
|
+
progress.committedOps += chunk.length;
|
|
690
|
+
if (result.warnings?.length) {
|
|
691
|
+
for (const w of result.warnings)
|
|
692
|
+
opts.report.notes.push(`server warning: ${w}`);
|
|
693
|
+
}
|
|
694
|
+
opts.onChunk?.(result.idMap);
|
|
572
695
|
}
|
|
573
|
-
throw new Error(`outbox tx ${txId} could not be committed after ${MAX_REBASE_RETRIES} rebases`);
|
|
574
696
|
}
|
|
575
697
|
function parkOffline(root, base, outbox, report) {
|
|
576
698
|
// Offline before we could pull: compute local ops vs base and park them.
|
|
577
|
-
const pagesMap = readPagesMap(root);
|
|
578
699
|
const ops = [];
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (!basePage)
|
|
582
|
-
continue;
|
|
700
|
+
const linker = instanceLinker(readComponentsMap(root), base.components);
|
|
701
|
+
for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
|
|
583
702
|
const filePath = join(root, mapping.file);
|
|
584
703
|
if (!existsSync(filePath))
|
|
585
704
|
continue;
|
|
586
705
|
const extracted = extract(readFileSync(filePath, 'utf8'));
|
|
587
706
|
for (const w of extracted.warnings)
|
|
588
707
|
report.warnings.push({ ...w, file: mapping.file });
|
|
589
|
-
if (extracted.dom
|
|
590
|
-
|
|
591
|
-
|
|
708
|
+
if (extracted.dom) {
|
|
709
|
+
linker.link(extracted.dom, mapping.file, warnInto(report, mapping.file));
|
|
710
|
+
fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
|
|
711
|
+
ops.push(...diffTree(baseEntity.root, extracted.dom));
|
|
592
712
|
}
|
|
593
713
|
}
|
|
594
714
|
if (ops.length > 0) {
|
|
@@ -598,20 +718,36 @@ function parkOffline(root, base, outbox, report) {
|
|
|
598
718
|
report.pushedOps = ops.length;
|
|
599
719
|
return report;
|
|
600
720
|
}
|
|
721
|
+
/** Every mapped page and component that exists in the base, with its mapping. */
|
|
722
|
+
function mappedEntities(root, base) {
|
|
723
|
+
const out = [];
|
|
724
|
+
for (const [id, mapping] of Object.entries(readPagesMap(root))) {
|
|
725
|
+
const page = base.pages.get(id);
|
|
726
|
+
if (page)
|
|
727
|
+
out.push([id, mapping, page]);
|
|
728
|
+
}
|
|
729
|
+
for (const [id, mapping] of Object.entries(readComponentsMap(root))) {
|
|
730
|
+
const component = base.components.get(id);
|
|
731
|
+
if (component)
|
|
732
|
+
out.push([id, mapping, component]);
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
}
|
|
601
736
|
export function verifyNoLocalDrift(root) {
|
|
602
737
|
// Used by doctor (Phase 8): re-extract every mapped file and compare to base.
|
|
603
738
|
const base = new BaseStore(root).readAll();
|
|
604
|
-
const
|
|
739
|
+
const linker = instanceLinker(readComponentsMap(root), base.components);
|
|
605
740
|
const results = [];
|
|
606
|
-
for (const [
|
|
607
|
-
const basePage = base.pages.get(pageId);
|
|
741
|
+
for (const [, mapping, baseEntity] of mappedEntities(root, base)) {
|
|
608
742
|
const filePath = join(root, mapping.file);
|
|
609
|
-
if (!
|
|
743
|
+
if (!existsSync(filePath))
|
|
610
744
|
continue;
|
|
611
745
|
const extracted = extract(readFileSync(filePath, 'utf8'));
|
|
612
|
-
if (extracted.dom
|
|
613
|
-
|
|
614
|
-
|
|
746
|
+
if (extracted.dom) {
|
|
747
|
+
linker.link(extracted.dom, mapping.file, () => undefined);
|
|
748
|
+
fillNonLiteralProps(extracted.dom, baseEntity.root, extracted.warnings);
|
|
749
|
+
}
|
|
750
|
+
results.push({ file: mapping.file, drifted: !deepEqual(extracted.dom, baseEntity.root) });
|
|
615
751
|
}
|
|
616
752
|
return results;
|
|
617
753
|
}
|