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