@1agh/maude 1.2.0 → 1.3.0
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 +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
// The studio's transaction client — DDR-241 §3, plan T13.
|
|
2
|
+
//
|
|
3
|
+
// In accepted-revisions mode the studio never writes the shared document. A
|
|
4
|
+
// persistent change becomes a PROPOSAL: exact request bytes are written to the
|
|
5
|
+
// durable outbox (`<designRoot>/_state/outbox/`, runtime state) BEFORE they are
|
|
6
|
+
// sent, so a crash, a dropped ACK or a restart can never turn one action into
|
|
7
|
+
// two or lose it. A retry sends the same bytes; an unknown outcome is resolved
|
|
8
|
+
// with `GET /transactions/:id` before anything is resent.
|
|
9
|
+
//
|
|
10
|
+
// Proposals are sent one at a time in creation order — a later edit to the
|
|
11
|
+
// same canvas can never overtake an earlier one.
|
|
12
|
+
|
|
13
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const PROTOCOL = 1;
|
|
18
|
+
|
|
19
|
+
export type LaneName = 'html' | 'css' | 'meta' | 'annotations' | 'comments';
|
|
20
|
+
|
|
21
|
+
export interface Operation {
|
|
22
|
+
op: string;
|
|
23
|
+
[k: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProposalResult {
|
|
27
|
+
protocol: number;
|
|
28
|
+
status: 'accepted' | 'rejected';
|
|
29
|
+
code?: string;
|
|
30
|
+
transactionId: string | null;
|
|
31
|
+
revision?: number;
|
|
32
|
+
actionId?: string;
|
|
33
|
+
merged?: { doc: string; lane: string; merged: boolean }[];
|
|
34
|
+
skipped?: { doc?: string; lane?: string; reason: string }[];
|
|
35
|
+
head?: string;
|
|
36
|
+
[k: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Bootstrap {
|
|
40
|
+
projectId: string;
|
|
41
|
+
mode: string;
|
|
42
|
+
epoch: number;
|
|
43
|
+
revision: number;
|
|
44
|
+
docs: {
|
|
45
|
+
doc: string;
|
|
46
|
+
entry: string;
|
|
47
|
+
generation: number;
|
|
48
|
+
path: string | null;
|
|
49
|
+
retired: boolean;
|
|
50
|
+
lanes: Record<string, { hash: string; effect: string }>;
|
|
51
|
+
}[];
|
|
52
|
+
dirs: string[];
|
|
53
|
+
you?: { actor: string; readOnly: boolean };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface OutboxAction {
|
|
57
|
+
kind?: string;
|
|
58
|
+
label: string;
|
|
59
|
+
operations: Operation[];
|
|
60
|
+
dependsOn?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OutboxEntry {
|
|
64
|
+
transactionId: string;
|
|
65
|
+
createdAt: number;
|
|
66
|
+
/** The exact request body — resent byte-for-byte once it has been sent. */
|
|
67
|
+
bytes: string;
|
|
68
|
+
label: string;
|
|
69
|
+
/** The action, so an epoch-stale entry can be rebased as a new transaction. */
|
|
70
|
+
action?: OutboxAction;
|
|
71
|
+
/** Written before the project id was known; bound (once) before sending. */
|
|
72
|
+
unbound?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TransactionClientOptions {
|
|
76
|
+
hubUrl: string;
|
|
77
|
+
token: () => string | null;
|
|
78
|
+
designRoot: string;
|
|
79
|
+
fetchImpl?: typeof fetch;
|
|
80
|
+
log?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
81
|
+
deviceId?: string;
|
|
82
|
+
/** Status hook — how many actions are waiting for acceptance. */
|
|
83
|
+
onPending?: (count: number) => void;
|
|
84
|
+
/** Every final result (accepted or rejected), in order. */
|
|
85
|
+
onResult?: (result: ProposalResult, action: { label: string; operations: Operation[] }) => void;
|
|
86
|
+
/** T29 — what a person waits on: pending count, oldest age, ack latency. */
|
|
87
|
+
onStats?: (stats: TransactionStats) => void;
|
|
88
|
+
now?: () => number;
|
|
89
|
+
/** Backoff between retries of an unacknowledged proposal. */
|
|
90
|
+
retryMs?: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface TransactionStats {
|
|
94
|
+
pending: number;
|
|
95
|
+
/** When the oldest unanswered change was made (ms epoch), or null. */
|
|
96
|
+
oldestPendingAt: number | null;
|
|
97
|
+
/** Durable acknowledgment latency — change made → accepted answer. */
|
|
98
|
+
ackMs: { last: number | null; p95: number | null; n: number };
|
|
99
|
+
/** Final rejections this session (conflicts, invalid source, rights…). */
|
|
100
|
+
rejected: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
|
|
104
|
+
|
|
105
|
+
/** The hub's lane hash: sha256 of the canonical UTF-8 lane text. */
|
|
106
|
+
export function laneHash(content: string): string {
|
|
107
|
+
return sha256(content);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const EMPTY_LANE_HASH = laneHash('');
|
|
111
|
+
|
|
112
|
+
export class TransactionError extends Error {
|
|
113
|
+
constructor(
|
|
114
|
+
message: string,
|
|
115
|
+
readonly code: string
|
|
116
|
+
) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = 'TransactionError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createTransactionClient(opts: TransactionClientOptions) {
|
|
123
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
124
|
+
const log = opts.log ?? console;
|
|
125
|
+
const now = opts.now ?? Date.now;
|
|
126
|
+
const retryMs = opts.retryMs ?? 1500;
|
|
127
|
+
const outboxDir = path.join(opts.designRoot, '_state', 'outbox');
|
|
128
|
+
const deviceId = opts.deviceId ?? 'studio';
|
|
129
|
+
const sessionId = randomUUID();
|
|
130
|
+
const base = opts.hubUrl.replace(/\/+$/, '');
|
|
131
|
+
|
|
132
|
+
let projectId: string | null = null;
|
|
133
|
+
let epoch = 0;
|
|
134
|
+
let stopped = false;
|
|
135
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
136
|
+
let pending = 0;
|
|
137
|
+
|
|
138
|
+
/** transactionId → when the change was made; the unanswered ones. */
|
|
139
|
+
const waiting = new Map<string, number>();
|
|
140
|
+
const acks: number[] = [];
|
|
141
|
+
let rejectedCount = 0;
|
|
142
|
+
function stats(): TransactionStats {
|
|
143
|
+
const sorted = [...acks].sort((a, b) => a - b);
|
|
144
|
+
return {
|
|
145
|
+
pending,
|
|
146
|
+
oldestPendingAt: waiting.size ? Math.min(...waiting.values()) : null,
|
|
147
|
+
ackMs: {
|
|
148
|
+
last: acks.length ? (acks[acks.length - 1] as number) : null,
|
|
149
|
+
p95: sorted.length
|
|
150
|
+
? (sorted[Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length))] as number)
|
|
151
|
+
: null,
|
|
152
|
+
n: acks.length,
|
|
153
|
+
},
|
|
154
|
+
rejected: rejectedCount,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const setPending = (delta: number) => {
|
|
158
|
+
pending = Math.max(0, pending + delta);
|
|
159
|
+
opts.onPending?.(pending);
|
|
160
|
+
opts.onStats?.(stats());
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
function headers(): Record<string, string> {
|
|
164
|
+
const token = opts.token();
|
|
165
|
+
return {
|
|
166
|
+
'content-type': 'application/json',
|
|
167
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function request(method: string, route: string, body?: string) {
|
|
172
|
+
const res = await fetchImpl(`${base}/api/projects/${projectId ?? 'current'}/v1/${route}`, {
|
|
173
|
+
method,
|
|
174
|
+
headers: headers(),
|
|
175
|
+
...(body !== undefined ? { body } : {}),
|
|
176
|
+
signal: AbortSignal.timeout(20_000),
|
|
177
|
+
});
|
|
178
|
+
const json = (await res.json().catch(() => null)) as unknown;
|
|
179
|
+
return { status: res.status, json };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function bootstrap(): Promise<Bootstrap> {
|
|
183
|
+
const { status, json } = await request('GET', 'bootstrap');
|
|
184
|
+
if (status === 404 || status === 405) {
|
|
185
|
+
// A hub without the route (or with the project unknown to it) speaks
|
|
186
|
+
// only the legacy protocol — an answer, not a failure.
|
|
187
|
+
throw new TransactionError(`bootstrap absent (${status})`, 'absent');
|
|
188
|
+
}
|
|
189
|
+
if (status !== 200 || !json || typeof json !== 'object') {
|
|
190
|
+
throw new TransactionError(`bootstrap failed (${status})`, 'bootstrap');
|
|
191
|
+
}
|
|
192
|
+
const b = json as Bootstrap;
|
|
193
|
+
projectId = b.projectId;
|
|
194
|
+
epoch = b.epoch;
|
|
195
|
+
return b;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writeOutbox(entry: OutboxEntry): string {
|
|
199
|
+
mkdirSync(outboxDir, { recursive: true });
|
|
200
|
+
const file = path.join(outboxDir, `${entry.createdAt}-${entry.transactionId}.json`);
|
|
201
|
+
const tmp = `${file}.tmp`;
|
|
202
|
+
writeFileSync(tmp, JSON.stringify(entry));
|
|
203
|
+
renameSync(tmp, file);
|
|
204
|
+
return file;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readOutbox(): { file: string; entry: OutboxEntry }[] {
|
|
208
|
+
let names: string[] = [];
|
|
209
|
+
try {
|
|
210
|
+
names = readdirSync(outboxDir).filter((n) => n.endsWith('.json'));
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
return names
|
|
215
|
+
.sort()
|
|
216
|
+
.map((n) => {
|
|
217
|
+
const file = path.join(outboxDir, n);
|
|
218
|
+
try {
|
|
219
|
+
return { file, entry: JSON.parse(readFileSync(file, 'utf8')) as OutboxEntry };
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
.filter((x): x is { file: string; entry: OutboxEntry } => x !== null);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Deliver one outbox entry to a FINAL result. Never gives up on an unknown
|
|
231
|
+
* outcome: a transport failure is resolved by asking for the transaction's
|
|
232
|
+
* result before the same bytes are sent again.
|
|
233
|
+
*/
|
|
234
|
+
async function deliver(file: string, entry: OutboxEntry): Promise<ProposalResult> {
|
|
235
|
+
let attempt = 0;
|
|
236
|
+
for (;;) {
|
|
237
|
+
if (stopped) throw new TransactionError('client stopped', 'stopped');
|
|
238
|
+
try {
|
|
239
|
+
if (attempt > 0) {
|
|
240
|
+
const known = await request('GET', `transactions/${entry.transactionId}`);
|
|
241
|
+
if (known.status === 200 && known.json) {
|
|
242
|
+
rmSync(file, { force: true });
|
|
243
|
+
return known.json as ProposalResult;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const { status, json } = await request('POST', 'proposals', entry.bytes);
|
|
247
|
+
const result = json as ProposalResult | null;
|
|
248
|
+
if (result && (result.status === 'accepted' || result.status === 'rejected')) {
|
|
249
|
+
if (result.status === 'rejected' && (result.code === 'retryable' || status >= 500)) {
|
|
250
|
+
throw new TransactionError('hub asked to retry', 'retryable');
|
|
251
|
+
}
|
|
252
|
+
rmSync(file, { force: true });
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
throw new TransactionError(`unexpected response ${status}`, 'retryable');
|
|
256
|
+
} catch (err) {
|
|
257
|
+
attempt++;
|
|
258
|
+
if (attempt === 1 || attempt % 10 === 0) {
|
|
259
|
+
log.warn(
|
|
260
|
+
`[sync/tx] ${entry.label}: not acknowledged yet (${(err as Error).message}); retrying`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
await sleep(Math.min(retryMs * 2 ** Math.min(attempt - 1, 4), 30_000));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Outbox files sort by this stamp, so it must be strictly increasing — two
|
|
269
|
+
// proposals in one millisecond must not replay in random-id order.
|
|
270
|
+
let lastStamp = 0;
|
|
271
|
+
const stamp = (): number => {
|
|
272
|
+
lastStamp = Math.max(now(), lastStamp + 1);
|
|
273
|
+
return lastStamp;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const newTransactionId = (): string => `tx_${randomUUID().replace(/-/g, '')}`;
|
|
277
|
+
|
|
278
|
+
/** Entries this process created — delivered by their own `propose`, never by a drain. */
|
|
279
|
+
const owned = new Set<string>();
|
|
280
|
+
/** epoch-stale rebases: old transaction id → the id it was re-proposed under. */
|
|
281
|
+
const aliases = new Map<string, string>();
|
|
282
|
+
|
|
283
|
+
function envelope(action: OutboxAction, transactionId: string): string {
|
|
284
|
+
return JSON.stringify({
|
|
285
|
+
protocol: PROTOCOL,
|
|
286
|
+
projectId,
|
|
287
|
+
epoch,
|
|
288
|
+
transactionId,
|
|
289
|
+
...(action.dependsOn?.length ? { dependsOn: action.dependsOn } : {}),
|
|
290
|
+
origin: { deviceId, sessionId, client: 'studio' },
|
|
291
|
+
action: { kind: action.kind ?? 'edit', label: action.label, operations: action.operations },
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function makeEntry(action: OutboxAction, transactionId: string): OutboxEntry {
|
|
296
|
+
return {
|
|
297
|
+
transactionId,
|
|
298
|
+
createdAt: stamp(),
|
|
299
|
+
bytes: envelope(action, transactionId),
|
|
300
|
+
label: action.label,
|
|
301
|
+
action,
|
|
302
|
+
...(projectId === null ? { unbound: true } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Take one outbox entry to its FINAL result. An entry written before this
|
|
308
|
+
* process knew the project is bound first — it was never sent, so its bytes
|
|
309
|
+
* may still change; once sent, they never do.
|
|
310
|
+
*/
|
|
311
|
+
async function settle(file: string, entry: OutboxEntry): Promise<ProposalResult> {
|
|
312
|
+
if (entry.unbound || projectId === null) {
|
|
313
|
+
if (projectId === null) await bootstrap();
|
|
314
|
+
if (entry.unbound && entry.action) {
|
|
315
|
+
entry = { ...entry, bytes: envelope(entry.action, entry.transactionId) };
|
|
316
|
+
delete entry.unbound;
|
|
317
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(entry));
|
|
318
|
+
renameSync(`${file}.tmp`, file);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
let result = await deliver(file, entry);
|
|
322
|
+
if (result.status === 'rejected' && result.code === 'epoch-stale' && entry.action) {
|
|
323
|
+
// A rebase is a NEW transaction under the current epoch, never a mutated
|
|
324
|
+
// retry — and anything that depended on the old id now depends on this.
|
|
325
|
+
await bootstrap();
|
|
326
|
+
const action = {
|
|
327
|
+
...entry.action,
|
|
328
|
+
...(entry.action.dependsOn
|
|
329
|
+
? { dependsOn: entry.action.dependsOn.map((id) => aliases.get(id) ?? id) }
|
|
330
|
+
: {}),
|
|
331
|
+
};
|
|
332
|
+
const next = makeEntry(action, newTransactionId());
|
|
333
|
+
aliases.set(entry.transactionId, next.transactionId);
|
|
334
|
+
const nextFile = writeOutbox(next);
|
|
335
|
+
owned.add(nextFile);
|
|
336
|
+
try {
|
|
337
|
+
result = await deliver(nextFile, next);
|
|
338
|
+
} finally {
|
|
339
|
+
owned.delete(nextFile);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (result.status === 'accepted') {
|
|
343
|
+
acks.push(Math.max(0, now() - entry.createdAt));
|
|
344
|
+
if (acks.length > 200) acks.shift();
|
|
345
|
+
} else rejectedCount++;
|
|
346
|
+
opts.onResult?.(result, {
|
|
347
|
+
label: entry.label,
|
|
348
|
+
operations: entry.action?.operations ?? [],
|
|
349
|
+
});
|
|
350
|
+
return result;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function enqueue<T>(work: () => Promise<T>): Promise<T> {
|
|
354
|
+
const next = chain.then(work, work);
|
|
355
|
+
chain = next.catch(() => {});
|
|
356
|
+
return next;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Propose one action. Resolves with the FINAL result.
|
|
361
|
+
*
|
|
362
|
+
* The proposal is written to the durable outbox SYNCHRONOUSLY, before this
|
|
363
|
+
* returns — not when its turn to be sent comes. A proposal waiting behind an
|
|
364
|
+
* unanswered one is exactly the one a crash would otherwise lose.
|
|
365
|
+
*
|
|
366
|
+
* `transactionId` may be chosen by the caller (`newTransactionId()`) so a
|
|
367
|
+
* later proposal can name this one in `dependsOn` before it is answered.
|
|
368
|
+
*/
|
|
369
|
+
function propose(action: {
|
|
370
|
+
kind?: string;
|
|
371
|
+
label: string;
|
|
372
|
+
operations: Operation[];
|
|
373
|
+
transactionId?: string;
|
|
374
|
+
dependsOn?: string[];
|
|
375
|
+
}): Promise<ProposalResult> {
|
|
376
|
+
const { transactionId, ...rest } = action;
|
|
377
|
+
let entry: OutboxEntry;
|
|
378
|
+
let file: string;
|
|
379
|
+
try {
|
|
380
|
+
entry = makeEntry(rest, transactionId ?? newTransactionId());
|
|
381
|
+
file = writeOutbox(entry);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
// Local persistence failed — say so; never pretend the edit is safe.
|
|
384
|
+
return Promise.reject(
|
|
385
|
+
new TransactionError(
|
|
386
|
+
`could not save the change locally: ${(err as Error).message}`,
|
|
387
|
+
'local-persistence'
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
owned.add(file);
|
|
392
|
+
waiting.set(entry.transactionId, entry.createdAt);
|
|
393
|
+
setPending(1);
|
|
394
|
+
return enqueue(() =>
|
|
395
|
+
settle(file, entry).finally(() => {
|
|
396
|
+
owned.delete(file);
|
|
397
|
+
waiting.delete(entry.transactionId);
|
|
398
|
+
setPending(-1);
|
|
399
|
+
})
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Resend what a previous process left in the outbox — in creation order, the
|
|
405
|
+
* same bytes, each resolved first in case its answer was simply lost.
|
|
406
|
+
*/
|
|
407
|
+
function drainOutbox(): Promise<ProposalResult[]> {
|
|
408
|
+
return enqueue(async () => {
|
|
409
|
+
const entries = readOutbox().filter(({ file }) => !owned.has(file));
|
|
410
|
+
const results: ProposalResult[] = [];
|
|
411
|
+
for (const { file, entry } of entries) {
|
|
412
|
+
waiting.set(entry.transactionId, entry.createdAt);
|
|
413
|
+
setPending(1);
|
|
414
|
+
try {
|
|
415
|
+
results.push(await settle(file, entry));
|
|
416
|
+
} finally {
|
|
417
|
+
waiting.delete(entry.transactionId);
|
|
418
|
+
setPending(-1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return results;
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** A read route (`history`, `lane`, `revisions`) — no retry, bounded. */
|
|
426
|
+
async function read(route: string, params: Record<string, string | number>): Promise<unknown> {
|
|
427
|
+
if (projectId === null) await bootstrap();
|
|
428
|
+
const qs = new URLSearchParams(
|
|
429
|
+
Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))
|
|
430
|
+
);
|
|
431
|
+
const { status, json } = await request('GET', `${route}?${qs.toString()}`);
|
|
432
|
+
if (status !== 200) throw new TransactionError(`${route} failed (${status})`, 'read');
|
|
433
|
+
return json;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
bootstrap,
|
|
438
|
+
propose,
|
|
439
|
+
read,
|
|
440
|
+
newTransactionId,
|
|
441
|
+
drainOutbox,
|
|
442
|
+
/** The epoch proposals are currently made under. */
|
|
443
|
+
get epoch() {
|
|
444
|
+
return epoch;
|
|
445
|
+
},
|
|
446
|
+
get projectId() {
|
|
447
|
+
return projectId;
|
|
448
|
+
},
|
|
449
|
+
get pending() {
|
|
450
|
+
return pending;
|
|
451
|
+
},
|
|
452
|
+
outboxSize: () => readOutbox().length,
|
|
453
|
+
stats,
|
|
454
|
+
stop() {
|
|
455
|
+
stopped = true;
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export type TransactionClient = ReturnType<typeof createTransactionClient>;
|