@1agh/maude 1.4.4 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/api.ts +4 -0
- package/apps/studio/canvas-comment-mount.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +62 -13
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +105 -60
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +35 -3
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/file-plane.ts +24 -1
- package/apps/studio/sync/index.ts +87 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-selection-set.tsx +9 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +9 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
// loaded via the importmap + Bun.build-produced ESM — there's no React Fast
|
|
25
25
|
// Refresh runtime to register with. Full-reload is the reliable path.
|
|
26
26
|
|
|
27
|
-
import { existsSync } from 'node:fs';
|
|
27
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { recallCanvasBuild } from './canvas-source-memo.ts';
|
|
31
|
+
import { type TextPatch, textOnlyPatches } from './canvas-text-patch.ts';
|
|
30
32
|
import type { Context } from './context.ts';
|
|
31
33
|
|
|
32
34
|
const DEBOUNCE_MS = 50;
|
|
@@ -54,6 +56,12 @@ export interface HmrMessage {
|
|
|
54
56
|
* lost race re-applied onto theirs).
|
|
55
57
|
*/
|
|
56
58
|
remote?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* F4 — `module` only: the change is nothing but the text of this element
|
|
61
|
+
* (versus the source of the canvas's last build). The iframe may show them at once; the remount it still
|
|
62
|
+
* performs confirms them. Absent whenever anything else changed.
|
|
63
|
+
*/
|
|
64
|
+
patches?: TextPatch[];
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
/** How long after a sync write its file's change still counts as remote. */
|
|
@@ -93,6 +101,30 @@ export function createHmrBroadcaster(
|
|
|
93
101
|
hard: 3,
|
|
94
102
|
};
|
|
95
103
|
|
|
104
|
+
// Diff the canvas as it is now against the source the iframe renders (its
|
|
105
|
+
// last build). Read at flush, not per event, so a burst diffs end to end.
|
|
106
|
+
// String scanning plus the build's locator — never a parse (see
|
|
107
|
+
// canvas-text-patch.ts: in a cell this is the credential-holding process).
|
|
108
|
+
function withTextPatches(msg: HmrMessage): HmrMessage {
|
|
109
|
+
const root = ctx.paths?.designRoot;
|
|
110
|
+
if (msg.mode !== 'module' || !msg.file?.endsWith('.tsx') || !root) return msg;
|
|
111
|
+
const abs = path.join(root, msg.file);
|
|
112
|
+
let now: string;
|
|
113
|
+
try {
|
|
114
|
+
now = readFileSync(abs, 'utf8');
|
|
115
|
+
} catch {
|
|
116
|
+
return msg;
|
|
117
|
+
}
|
|
118
|
+
const built = recallCanvasBuild(abs);
|
|
119
|
+
if (built === undefined) return msg;
|
|
120
|
+
try {
|
|
121
|
+
const patches = textOnlyPatches(built.source, now, built.locator);
|
|
122
|
+
return patches?.length ? { ...msg, patches } : msg;
|
|
123
|
+
} catch {
|
|
124
|
+
return msg; // the remount still renders it
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
96
128
|
function flush() {
|
|
97
129
|
// A pending `hard` supersedes the per-file queue — every open canvas does a
|
|
98
130
|
// full reload anyway, so the softer messages would be redundant churn.
|
|
@@ -100,7 +132,7 @@ export function createHmrBroadcaster(
|
|
|
100
132
|
if (hard?.mode === 'hard') {
|
|
101
133
|
broadcast(hard);
|
|
102
134
|
} else {
|
|
103
|
-
for (const msg of pendingByKey.values()) broadcast(msg);
|
|
135
|
+
for (const msg of pendingByKey.values()) broadcast(withTextPatches(msg));
|
|
104
136
|
}
|
|
105
137
|
pendingByKey.clear();
|
|
106
138
|
pending = null;
|
|
@@ -116,6 +148,11 @@ export function createHmrBroadcaster(
|
|
|
116
148
|
|
|
117
149
|
// Files sync just wrote (see HmrMessage.remote), with the time of the write.
|
|
118
150
|
const projectedAt = new Map<string, number>();
|
|
151
|
+
// A real watcher and the cell fallback can report the same completed write
|
|
152
|
+
// more than 50 ms apart. A second import invalidates the first even when its
|
|
153
|
+
// bytes are already arriving. Deduplicate disk VERSIONS, never merely paths
|
|
154
|
+
// or a time window: another edit to this file must still reach the canvas.
|
|
155
|
+
const remoteVersions = new Map<string, string>();
|
|
119
156
|
const isRemote = (rel: string | undefined): boolean => {
|
|
120
157
|
if (!rel) return false;
|
|
121
158
|
const at = projectedAt.get(rel);
|
|
@@ -146,7 +183,25 @@ export function createHmrBroadcaster(
|
|
|
146
183
|
const offAny = ctx.bus.on('fs:any', (rel: string) => {
|
|
147
184
|
const msg = classify(rel);
|
|
148
185
|
if (!msg) return;
|
|
149
|
-
|
|
186
|
+
const normalized = rel.replace(/\\/g, '/');
|
|
187
|
+
if (isRemote(normalized) || isRemote(msg.file)) msg.remote = true;
|
|
188
|
+
if (msg.remote && (msg.mode === 'module' || msg.mode === 'css') && ctx.paths.designRoot) {
|
|
189
|
+
try {
|
|
190
|
+
const s = statSync(path.join(ctx.paths.designRoot, normalized), { bigint: true });
|
|
191
|
+
// Nanosecond ctime also detects a same-size rewrite with restored mtime;
|
|
192
|
+
// inode detects atomic replacement. No source or large media is read.
|
|
193
|
+
const version = `${msg.mode}:${msg.file}:${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
|
|
194
|
+
if (remoteVersions.get(normalized) === version) return;
|
|
195
|
+
remoteVersions.delete(normalized);
|
|
196
|
+
remoteVersions.set(normalized, version);
|
|
197
|
+
if (remoteVersions.size > 256) {
|
|
198
|
+
const oldest = remoteVersions.keys().next().value;
|
|
199
|
+
if (oldest !== undefined) remoteVersions.delete(oldest);
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
remoteVersions.delete(normalized); // missing/unreadable: fail open
|
|
203
|
+
}
|
|
204
|
+
} else remoteVersions.delete(normalized);
|
|
150
205
|
enqueue(msg);
|
|
151
206
|
});
|
|
152
207
|
|
|
@@ -157,6 +212,7 @@ export function createHmrBroadcaster(
|
|
|
157
212
|
if (pending) clearTimeout(pending);
|
|
158
213
|
pending = null;
|
|
159
214
|
pendingByKey.clear();
|
|
215
|
+
remoteVersions.clear();
|
|
160
216
|
},
|
|
161
217
|
};
|
|
162
218
|
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { buildCanvasModule } from './canvas-build.ts';
|
|
|
34
34
|
import { buildCanvasSandboxed, buildStats } from './canvas-build-sandbox.ts';
|
|
35
35
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
36
36
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
37
|
+
import { rememberCanvasBuild } from './canvas-source-memo.ts';
|
|
37
38
|
import { createCloudEndpoints } from './cloud/endpoints.ts';
|
|
38
39
|
import type { AiActivity } from './collab/ai-activity.ts';
|
|
39
40
|
import type { Context } from './context.ts';
|
|
@@ -687,6 +688,7 @@ export async function serveCanvasTsx(
|
|
|
687
688
|
deps,
|
|
688
689
|
};
|
|
689
690
|
canvasCache.set(absPath, cached);
|
|
691
|
+
rememberCanvasBuild(absPath, { source, locator: result.locator });
|
|
690
692
|
await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
|
|
691
693
|
return respondWithCanvasModule(req, cached);
|
|
692
694
|
}
|
|
@@ -725,6 +727,7 @@ export async function serveCanvasTsx(
|
|
|
725
727
|
deps,
|
|
726
728
|
};
|
|
727
729
|
canvasCache.set(absPath, cached);
|
|
730
|
+
rememberCanvasBuild(absPath, { source, locator: result.locator });
|
|
728
731
|
// Persist the locator map. Awaited so the inspector / Phase-12 layers
|
|
729
732
|
// panel sees a consistent (cdId -> source) view by the time the canvas
|
|
730
733
|
// mounts. Per-path mutex inside writeLocator() makes concurrent transpiles
|
|
@@ -92,6 +92,19 @@ export interface AcceptedColdStartInput {
|
|
|
92
92
|
projection: DocProjection;
|
|
93
93
|
historyDir?: string;
|
|
94
94
|
journal?: SyncJournal;
|
|
95
|
+
/**
|
|
96
|
+
* This disk's own html proposal a previous run left unanswered, which the
|
|
97
|
+
* outbox drain has just had accepted. The disk was saved on top of it, so it
|
|
98
|
+
* is the base — the recovery slot still holds the value before it.
|
|
99
|
+
*/
|
|
100
|
+
ownAccepted?: string | null;
|
|
101
|
+
/**
|
|
102
|
+
* Did the project ever accept exactly this value? A disk with no record of
|
|
103
|
+
* its base that holds a value the store accepted at some revision holds an
|
|
104
|
+
* OLDER accepted state (a checkout restored from a backup — every cloud
|
|
105
|
+
* cell wake), not an edit: it is materialized, never held.
|
|
106
|
+
*/
|
|
107
|
+
wasAccepted?: (content: string) => Promise<boolean | null>;
|
|
95
108
|
createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
|
|
96
109
|
status: 'accepted' | 'rejected';
|
|
97
110
|
code?: string;
|
|
@@ -145,14 +158,27 @@ export async function acceptedColdStart(
|
|
|
145
158
|
// An invalid local body cannot be proposed; the projection keeps its bytes
|
|
146
159
|
// in recovery and reports it when it materializes the accepted source.
|
|
147
160
|
verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
|
|
161
|
+
} else if (i.ownAccepted != null && localHtml === i.ownAccepted) {
|
|
162
|
+
// Disk IS the drained, accepted save; its publication may still be on the
|
|
163
|
+
// way. Nothing to propose, and nothing older to write over it.
|
|
164
|
+
verdicts.push({ lane: 'html', decision: 'agreed', local: localHtml });
|
|
165
|
+
i.projection.adoptOwnAccepted(i.ownAccepted);
|
|
148
166
|
} else {
|
|
149
167
|
const d = decideSourceLane({
|
|
150
168
|
local: localHtml,
|
|
151
169
|
accepted: acceptedHtml,
|
|
152
|
-
knownBase:
|
|
170
|
+
knownBase:
|
|
171
|
+
i.ownAccepted ??
|
|
172
|
+
(i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null),
|
|
153
173
|
baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
|
|
154
174
|
});
|
|
155
|
-
|
|
175
|
+
const older =
|
|
176
|
+
d.decision === 'hold' && localHtml !== null && (await i.wasAccepted?.(localHtml)) === true;
|
|
177
|
+
verdicts.push({
|
|
178
|
+
lane: 'html',
|
|
179
|
+
...(older ? { decision: 'materialize' as const } : d),
|
|
180
|
+
...(localHtml !== null ? { local: localHtml } : {}),
|
|
181
|
+
});
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
// ---- css — opaque text, journal-checkpointed
|
|
@@ -164,7 +190,13 @@ export async function acceptedColdStart(
|
|
|
164
190
|
knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
|
|
165
191
|
baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
|
|
166
192
|
});
|
|
167
|
-
|
|
193
|
+
const older =
|
|
194
|
+
d.decision === 'hold' && localCss !== null && (await i.wasAccepted?.(localCss)) === true;
|
|
195
|
+
verdicts.push({
|
|
196
|
+
lane: 'css',
|
|
197
|
+
...(older ? { decision: 'materialize' as const } : d),
|
|
198
|
+
...(localCss !== null ? { local: localCss } : {}),
|
|
199
|
+
});
|
|
168
200
|
}
|
|
169
201
|
|
|
170
202
|
// ---- meta — the shared layout keys (viewport never travels)
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// Nothing here writes a Y.Doc. The documents change when the hub publishes the
|
|
13
13
|
// accepted revision, through the same providers that deliver a peer's edit.
|
|
14
14
|
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
15
18
|
import { createActionStage, type StageSummary } from './action-stage.ts';
|
|
16
19
|
import type { AcceptedLaneLink, LaneProposal, ProposalOutcome } from './projection.ts';
|
|
17
20
|
import {
|
|
@@ -71,13 +74,61 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
71
74
|
retryMs: opts.retryMs,
|
|
72
75
|
});
|
|
73
76
|
|
|
74
|
-
|
|
77
|
+
// Every proposal this link sends goes out AFTER the creates queued before it
|
|
78
|
+
// (a lane edit of a canvas being added must never overtake its doc.create on
|
|
79
|
+
// the serialized queue); see `queueCreate` below.
|
|
80
|
+
const propose: TransactionClient['propose'] = (action) => {
|
|
81
|
+
flushCreates();
|
|
82
|
+
// A proposal about a canvas whose batched create is still unanswered waits
|
|
83
|
+
// until that create is settled or re-queued on its own: a refused batch
|
|
84
|
+
// falls back to one create each, and those must stay AHEAD of it.
|
|
85
|
+
const waits = [
|
|
86
|
+
...new Set(
|
|
87
|
+
action.operations
|
|
88
|
+
.map((op) => (typeof op.doc === 'string' ? createsInFlight.get(op.doc) : undefined))
|
|
89
|
+
.filter((w): w is Promise<void> => !!w)
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
if (!waits.length) return client.propose(action);
|
|
93
|
+
return Promise.all(waits).then(() => client.propose(action));
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// THE LAST VERDICT SURVIVES A RESTART (F3/S06). Held only in memory, a
|
|
97
|
+
// desktop that restarted without the network came back 'unknown', treated
|
|
98
|
+
// its offline edits as legacy shared-document writes, and the hub fenced
|
|
99
|
+
// those raw updates: the work never reached the project while the status
|
|
100
|
+
// read "synced". The hub still has the last word — the next bootstrap
|
|
101
|
+
// replaces this — but until it answers, the answer it last gave stands.
|
|
102
|
+
// Keyed by hub, so a relinked copy starts from 'unknown' again.
|
|
103
|
+
const modeFile = path.join(opts.designRoot, '_state', 'accepted-mode.json');
|
|
104
|
+
const loadMode = (): AcceptedMode => {
|
|
105
|
+
try {
|
|
106
|
+
const v = JSON.parse(readFileSync(modeFile, 'utf8'));
|
|
107
|
+
if (v?.v === 1 && v.hub === opts.hubUrl && (v.mode === 'transactions' || v.mode === 'legacy'))
|
|
108
|
+
return v.mode;
|
|
109
|
+
} catch {
|
|
110
|
+
/* absent or unreadable — ask the hub */
|
|
111
|
+
}
|
|
112
|
+
return 'unknown';
|
|
113
|
+
};
|
|
114
|
+
const saveMode = (next: AcceptedMode): void => {
|
|
115
|
+
if (next === 'unknown') return;
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(path.dirname(modeFile), { recursive: true });
|
|
118
|
+
const tmp = `${modeFile}.tmp`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, hub: opts.hubUrl, mode: next, at: Date.now() }));
|
|
120
|
+
renameSync(tmp, modeFile);
|
|
121
|
+
} catch {
|
|
122
|
+
/* the in-memory verdict still holds for this run */
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
let mode: AcceptedMode = loadMode();
|
|
75
126
|
let manifest: Bootstrap | null = null;
|
|
76
127
|
|
|
77
128
|
// T16 — AI and multi-file action boundaries (see action-stage.ts).
|
|
78
129
|
const stage = createActionStage({
|
|
79
130
|
designRoot: opts.designRoot,
|
|
80
|
-
propose: (action) =>
|
|
131
|
+
propose: (action) => propose(action),
|
|
81
132
|
newTransactionId: client.newTransactionId,
|
|
82
133
|
onChange: opts.onStage,
|
|
83
134
|
log,
|
|
@@ -96,12 +147,16 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
96
147
|
if (next !== mode && mode !== 'unknown') {
|
|
97
148
|
log.log(`[sync/tx] project save mode changed: ${mode} → ${next}`);
|
|
98
149
|
}
|
|
150
|
+
if (next !== mode) saveMode(next);
|
|
99
151
|
mode = next;
|
|
100
152
|
manifest = b;
|
|
101
153
|
opts.onBootstrap?.(b);
|
|
102
154
|
return b;
|
|
103
155
|
} catch (err) {
|
|
104
|
-
if (err instanceof TransactionError && err.code === 'absent')
|
|
156
|
+
if (err instanceof TransactionError && err.code === 'absent') {
|
|
157
|
+
if (mode !== 'legacy') saveMode('legacy');
|
|
158
|
+
mode = 'legacy';
|
|
159
|
+
}
|
|
105
160
|
return null;
|
|
106
161
|
}
|
|
107
162
|
}
|
|
@@ -114,7 +169,10 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
114
169
|
* confirmed by the next `refresh()`.
|
|
115
170
|
*/
|
|
116
171
|
function noteMode(next: 'transactions' | 'legacy'): void {
|
|
117
|
-
if (next !== mode)
|
|
172
|
+
if (next !== mode) {
|
|
173
|
+
log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
|
|
174
|
+
saveMode(next);
|
|
175
|
+
}
|
|
118
176
|
mode = next;
|
|
119
177
|
}
|
|
120
178
|
|
|
@@ -133,24 +191,22 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
133
191
|
const doc = opts.docNameFor(slug);
|
|
134
192
|
if (stage.captures(slug, p.stageable === true)) return stage.capture(slug, doc, p);
|
|
135
193
|
const send = (dependsOn: string[] | undefined) =>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
})
|
|
153
|
-
.then(outcome);
|
|
194
|
+
propose({
|
|
195
|
+
kind: 'edit',
|
|
196
|
+
label: LANE_LABEL[p.lane],
|
|
197
|
+
transactionId: p.transactionId,
|
|
198
|
+
dependsOn,
|
|
199
|
+
operations: [
|
|
200
|
+
{
|
|
201
|
+
op: 'lane.replace',
|
|
202
|
+
doc,
|
|
203
|
+
lane: p.lane,
|
|
204
|
+
content: p.content,
|
|
205
|
+
baseContent: p.baseContent,
|
|
206
|
+
...(p.writeId ? { writeId: p.writeId } : {}),
|
|
207
|
+
},
|
|
208
|
+
],
|
|
209
|
+
}).then(outcome);
|
|
154
210
|
if (stage.holdsDependency(p.dependsOn)) {
|
|
155
211
|
// Authored on top of an agent's unpublished bytes: it waits behind
|
|
156
212
|
// the stage, and goes out right after the group (or is discarded
|
|
@@ -175,13 +231,86 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
175
231
|
* it). The caller proceeds with its local change either way — the designer
|
|
176
232
|
* never waits on the network to move a canvas.
|
|
177
233
|
*/
|
|
234
|
+
// ONE PROPOSAL FOR A BURST OF NEW CANVASES. A fresh link adds every local
|
|
235
|
+
// canvas the project does not have, each from its own cold start, all at
|
|
236
|
+
// once; sent one by one they are that many round trips on the one
|
|
237
|
+
// serialized transaction queue, and the first edit waits behind all of them
|
|
238
|
+
// (F3 S14 on the cloud cell: 77 creates, 44 s). Creates that arrive within a
|
|
239
|
+
// short window travel together; a refused batch falls back to one proposal
|
|
240
|
+
// each, so one canvas the project will not take cannot hold back the rest.
|
|
241
|
+
const CREATE_WINDOW_MS = 25;
|
|
242
|
+
const CREATE_BATCH_MAX = 100;
|
|
243
|
+
const CREATE_BATCH_BYTES = 4 * 1024 * 1024;
|
|
244
|
+
type PendingCreate = { op: Operation; bytes: number; resolve: (o: ProposalOutcome) => void };
|
|
245
|
+
let createBatch: PendingCreate[] = [];
|
|
246
|
+
let createBatchBytes = 0;
|
|
247
|
+
let createTimer: ReturnType<typeof setTimeout> | null = null;
|
|
248
|
+
/** doc → the gate a batched create of it holds until settled or re-queued. */
|
|
249
|
+
const createsInFlight = new Map<string, Promise<void>>();
|
|
250
|
+
const proposeCreates = (ops: Operation[]) =>
|
|
251
|
+
client
|
|
252
|
+
.propose({
|
|
253
|
+
kind: 'canvas.create',
|
|
254
|
+
label: ops.length === 1 ? 'Create canvas' : `Add ${ops.length} canvases`,
|
|
255
|
+
operations: ops,
|
|
256
|
+
})
|
|
257
|
+
.then(outcome);
|
|
258
|
+
function flushCreates(): void {
|
|
259
|
+
if (createTimer) clearTimeout(createTimer);
|
|
260
|
+
createTimer = null;
|
|
261
|
+
const batch = createBatch;
|
|
262
|
+
createBatch = [];
|
|
263
|
+
createBatchBytes = 0;
|
|
264
|
+
if (!batch.length) return;
|
|
265
|
+
const docs = batch.map((c) => c.op.doc).filter((d): d is string => typeof d === 'string');
|
|
266
|
+
let settled: () => void = () => {};
|
|
267
|
+
const gate = new Promise<void>((r) => {
|
|
268
|
+
settled = r;
|
|
269
|
+
});
|
|
270
|
+
for (const d of docs) createsInFlight.set(d, gate);
|
|
271
|
+
const release = () => {
|
|
272
|
+
for (const d of docs) if (createsInFlight.get(d) === gate) createsInFlight.delete(d);
|
|
273
|
+
settled();
|
|
274
|
+
};
|
|
275
|
+
const fail = (err: unknown) => {
|
|
276
|
+
release();
|
|
277
|
+
for (const c of batch)
|
|
278
|
+
c.resolve({ status: 'rejected', code: (err as TransactionError)?.code ?? 'failed' });
|
|
279
|
+
};
|
|
280
|
+
void proposeCreates(batch.map((c) => c.op)).then((o) => {
|
|
281
|
+
if (o.status === 'accepted' || batch.length === 1) {
|
|
282
|
+
release();
|
|
283
|
+
for (const c of batch) c.resolve(o);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Each create is queued (and so ahead of anything waiting on the gate)
|
|
287
|
+
// before the gate opens.
|
|
288
|
+
const each = batch.map((c) => proposeCreates([c.op]));
|
|
289
|
+
release();
|
|
290
|
+
each.forEach((answer, i) => void answer.then(batch[i].resolve, () => batch[i].resolve(o)));
|
|
291
|
+
}, fail);
|
|
292
|
+
}
|
|
293
|
+
function queueCreate(op: Operation): Promise<ProposalOutcome> {
|
|
294
|
+
const bytes = Buffer.byteLength(JSON.stringify(op), 'utf8');
|
|
295
|
+
if (
|
|
296
|
+
createBatch.length &&
|
|
297
|
+
(createBatch.length >= CREATE_BATCH_MAX || createBatchBytes + bytes > CREATE_BATCH_BYTES)
|
|
298
|
+
)
|
|
299
|
+
flushCreates();
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
createBatch.push({ op, bytes, resolve });
|
|
302
|
+
createBatchBytes += bytes;
|
|
303
|
+
createTimer ??= setTimeout(flushCreates, CREATE_WINDOW_MS);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
178
307
|
function structural(
|
|
179
308
|
label: string,
|
|
180
309
|
kind: string,
|
|
181
310
|
operations: Operation[],
|
|
182
311
|
waitMs: number
|
|
183
312
|
): Promise<StructuralOutcome> {
|
|
184
|
-
const answer =
|
|
313
|
+
const answer = propose({ kind, label, operations }).then(outcome);
|
|
185
314
|
if (!Number.isFinite(waitMs)) return answer;
|
|
186
315
|
return Promise.race([
|
|
187
316
|
answer,
|
|
@@ -214,12 +343,19 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
|
|
|
214
343
|
): Promise<StructuralOutcome> {
|
|
215
344
|
const clean: Record<string, string> = {};
|
|
216
345
|
for (const [lane, v] of Object.entries(lanes)) if (v) clean[lane] = v;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
346
|
+
const answer = queueCreate({
|
|
347
|
+
op: 'doc.create',
|
|
348
|
+
doc: opts.docNameFor(slug),
|
|
349
|
+
path: rel,
|
|
350
|
+
lanes: clean,
|
|
351
|
+
});
|
|
352
|
+
if (!Number.isFinite(waitMs)) return answer;
|
|
353
|
+
return Promise.race([
|
|
354
|
+
answer,
|
|
355
|
+
new Promise<StructuralOutcome>((resolve) =>
|
|
356
|
+
setTimeout(() => resolve({ status: 'accepted', queued: true }), waitMs)
|
|
357
|
+
),
|
|
358
|
+
]);
|
|
223
359
|
},
|
|
224
360
|
deleteDoc(slug: string, waitMs = 8_000): Promise<StructuralOutcome> {
|
|
225
361
|
return structural(
|
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
statSync,
|
|
62
62
|
writeFileSync,
|
|
63
63
|
} from 'node:fs';
|
|
64
|
+
import { open as openAsync } from 'node:fs/promises';
|
|
64
65
|
import path from 'node:path';
|
|
65
66
|
|
|
66
67
|
import { conflictCopyName, decideFile, type FileState } from './decide-file.ts';
|
|
@@ -112,6 +113,28 @@ export function sha256File(abs: string): string {
|
|
|
112
113
|
return hash.digest('hex');
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The same digest, yielding to the event loop between chunks. A received video
|
|
118
|
+
* is verified on the path every canvas sync shares: hashing 513 MiB in one
|
|
119
|
+
* synchronous run held a teammate's edit back ~5 s on a receiving peer
|
|
120
|
+
* (F3 S14, 2026-09-23).
|
|
121
|
+
*/
|
|
122
|
+
export async function sha256FileAsync(abs: string): Promise<string> {
|
|
123
|
+
const hash = createHash('sha256');
|
|
124
|
+
const buf = Buffer.allocUnsafe(1024 * 1024);
|
|
125
|
+
const fh = await openAsync(abs, 'r');
|
|
126
|
+
try {
|
|
127
|
+
for (;;) {
|
|
128
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
|
129
|
+
if (bytesRead <= 0) break;
|
|
130
|
+
hash.update(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
await fh.close();
|
|
134
|
+
}
|
|
135
|
+
return hash.digest('hex');
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
/**
|
|
116
139
|
* What the door accepts, until it tells us otherwise.
|
|
117
140
|
*
|
|
@@ -901,7 +924,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
901
924
|
}
|
|
902
925
|
let got: string;
|
|
903
926
|
try {
|
|
904
|
-
got =
|
|
927
|
+
got = await sha256FileAsync(staged);
|
|
905
928
|
} catch {
|
|
906
929
|
return { ok: false, reason: 'could not read the downloaded file back' };
|
|
907
930
|
}
|