@marimo-team/frontend 0.25.3-dev100 → 0.25.3-dev102
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/package.json +1 -1
- package/src/core/document/__tests__/lifecycle-scenarios.test.ts +61 -0
- package/src/core/document/__tests__/model.test.ts +6 -331
- package/src/core/document/__tests__/protocol-fixtures.ts +38 -0
- package/src/core/document/__tests__/recovery-scenarios.test.ts +230 -0
- package/src/core/document/__tests__/scenario-fixture.ts +42 -0
- package/src/core/document/__tests__/scenarios.test.ts +205 -0
- package/src/core/document/__tests__/state.test.ts +5 -13
- package/src/core/document/__tests__/submission.test.ts +24 -167
- package/src/core/document/__tests__/test-host.test.ts +92 -0
- package/src/core/document/__tests__/test-host.ts +247 -0
- package/src/core/document/__tests__/test-notebook.ts +139 -0
- package/src/core/document/__tests__/user.ts +61 -0
- package/src/core/document/index.ts +3 -1
- package/src/core/document/model.ts +7 -8
- package/src/core/document/select.ts +32 -0
- package/src/core/document/state.ts +3 -30
- package/src/core/document/sync.ts +29 -25
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { atom, createStore } from "jotai/vanilla";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { Document } from "../../host/resources/document";
|
|
6
|
+
import { selectCell } from "../select";
|
|
6
7
|
import { createDocumentState, type DocumentSubmission } from "../state";
|
|
7
8
|
import { cell, document, withCells } from "./fixtures";
|
|
8
9
|
|
|
@@ -25,7 +26,7 @@ function setup() {
|
|
|
25
26
|
store,
|
|
26
27
|
source,
|
|
27
28
|
...model,
|
|
28
|
-
|
|
29
|
+
cellAtom: (id: string) => selectCell(model.documentAtom, id),
|
|
29
30
|
submit() {
|
|
30
31
|
const submission = store.set(model.submitAtom);
|
|
31
32
|
if (submission === null) {
|
|
@@ -104,106 +105,39 @@ describe("document submissions", () => {
|
|
|
104
105
|
);
|
|
105
106
|
|
|
106
107
|
it.each(["response", "stream"] as const)(
|
|
107
|
-
"
|
|
108
|
+
"does not notify cell subscribers when the %s acknowledges a superseded draft",
|
|
108
109
|
(first) => {
|
|
109
|
-
const {
|
|
110
|
-
store,
|
|
111
|
-
source,
|
|
112
|
-
editAtom,
|
|
113
|
-
cellAtom,
|
|
114
|
-
changesAtom,
|
|
115
|
-
submitAtom,
|
|
116
|
-
submit,
|
|
117
|
-
settle,
|
|
118
|
-
} = setup();
|
|
110
|
+
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
119
111
|
store.set(editAtom, (tx) => {
|
|
120
112
|
tx.setCode("c1", "sent");
|
|
121
|
-
tx.
|
|
113
|
+
tx.createCell({ id: "c3", code: "new" });
|
|
122
114
|
});
|
|
123
115
|
const sent = submit();
|
|
124
|
-
store.set(editAtom, (tx) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
etag: "accepted",
|
|
128
|
-
config: { ...cell("c1", "").config, hide_code: true },
|
|
129
|
-
}),
|
|
116
|
+
store.set(editAtom, (tx) => {
|
|
117
|
+
tx.setCode("c1", "still typing");
|
|
118
|
+
tx.deleteCell("c3");
|
|
130
119
|
});
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expect(store.set(submitAtom)).toBeNull();
|
|
139
|
-
arrivals[first === "response" ? "stream" : "response"]();
|
|
140
|
-
expect(onCell).not.toHaveBeenCalled();
|
|
141
|
-
expect(store.get(cellAtom("c1"))?.code).toBe("still typing");
|
|
142
|
-
expect(store.get(changesAtom)).toEqual([
|
|
143
|
-
{
|
|
144
|
-
kind: "put_cell",
|
|
145
|
-
base_etag: "accepted",
|
|
146
|
-
cell: {
|
|
147
|
-
id: "c1",
|
|
148
|
-
code: "still typing",
|
|
149
|
-
name: "_",
|
|
150
|
-
config: confirmed.cells.get("c1")?.config,
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
]);
|
|
154
|
-
const next = submit();
|
|
155
|
-
settle(sent, confirmed);
|
|
156
|
-
expect(store.set(submitAtom)).toBeNull();
|
|
157
|
-
const updated = withCells(confirmed, {
|
|
158
|
-
c1: cell("c1", "still typing", {
|
|
159
|
-
config: confirmed.cells.get("c1")!.config,
|
|
160
|
-
}),
|
|
120
|
+
const onCode = vi.fn();
|
|
121
|
+
const onCreation = vi.fn();
|
|
122
|
+
const unsubscribeCode = store.sub(cellAtom("c1"), onCode);
|
|
123
|
+
const unsubscribeCreation = store.sub(cellAtom("c3"), onCreation);
|
|
124
|
+
const confirmed = withCells(document(), {
|
|
125
|
+
c1: cell("c1", "sent", { etag: "accepted", version: 2 }),
|
|
126
|
+
c3: cell("c3", "new"),
|
|
161
127
|
});
|
|
162
|
-
settle(next, updated);
|
|
163
|
-
store.set(source, updated);
|
|
164
|
-
expect(store.get(changesAtom)).toEqual([]);
|
|
165
|
-
},
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
it.each(["response", "stream"] as const)(
|
|
169
|
-
"never shows a cancelled creation when the %s arrives first",
|
|
170
|
-
(first) => {
|
|
171
|
-
const {
|
|
172
|
-
store,
|
|
173
|
-
source,
|
|
174
|
-
editAtom,
|
|
175
|
-
cellAtom,
|
|
176
|
-
orderAtom,
|
|
177
|
-
changesAtom,
|
|
178
|
-
submit,
|
|
179
|
-
settle,
|
|
180
|
-
} = setup();
|
|
181
|
-
store.set(editAtom, (tx) => tx.createCell({ id: "c3", code: "new" }));
|
|
182
|
-
const sent = submit();
|
|
183
|
-
store.set(editAtom, (tx) => tx.deleteCell("c3"));
|
|
184
|
-
const onCell = vi.fn();
|
|
185
|
-
store.sub(cellAtom("c3"), onCell);
|
|
186
|
-
// An unrelated revision before the creation must not forget the deletion.
|
|
187
|
-
const intermediate = withCells(document(), { c2: cell("c2", "remote") });
|
|
188
|
-
store.set(source, intermediate);
|
|
189
|
-
const confirmed = withCells(intermediate, { c3: cell("c3", "new") });
|
|
190
128
|
const arrivals = {
|
|
191
129
|
response: () => settle(sent, confirmed),
|
|
192
130
|
stream: () => store.set(source, confirmed),
|
|
193
131
|
};
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
store.set(source, removed);
|
|
204
|
-
settle(deletion, removed);
|
|
205
|
-
settle(sent, confirmed);
|
|
206
|
-
expect(store.get(changesAtom)).toEqual([]);
|
|
132
|
+
try {
|
|
133
|
+
arrivals[first]();
|
|
134
|
+
arrivals[first === "response" ? "stream" : "response"]();
|
|
135
|
+
expect(onCode).not.toHaveBeenCalled();
|
|
136
|
+
expect(onCreation).not.toHaveBeenCalled();
|
|
137
|
+
} finally {
|
|
138
|
+
unsubscribeCode();
|
|
139
|
+
unsubscribeCreation();
|
|
140
|
+
}
|
|
207
141
|
},
|
|
208
142
|
);
|
|
209
143
|
|
|
@@ -227,36 +161,6 @@ describe("document submissions", () => {
|
|
|
227
161
|
expect(store.set(submitAtom)).toBeNull();
|
|
228
162
|
});
|
|
229
163
|
|
|
230
|
-
it.each(["before reset", "during reset", "after snapshot"] as const)(
|
|
231
|
-
"confirms a submission across reset when its response arrives %s",
|
|
232
|
-
(arrival) => {
|
|
233
|
-
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
234
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "unsaved"));
|
|
235
|
-
const sent = submit();
|
|
236
|
-
const old = withCells(document(), { c1: cell("c1", "unsaved") });
|
|
237
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "newer typing"));
|
|
238
|
-
if (arrival === "before reset") {
|
|
239
|
-
settle(sent, old);
|
|
240
|
-
}
|
|
241
|
-
store.set(source, null);
|
|
242
|
-
if (arrival === "during reset") {
|
|
243
|
-
settle(sent, old);
|
|
244
|
-
}
|
|
245
|
-
const fresh = withCells(old, { c2: cell("c2", "unrelated") });
|
|
246
|
-
store.set(source, fresh);
|
|
247
|
-
if (arrival === "after snapshot") {
|
|
248
|
-
settle(sent, old);
|
|
249
|
-
}
|
|
250
|
-
expect(store.get(cellAtom("c1"))?.code).toBe("newer typing");
|
|
251
|
-
const retried = submit();
|
|
252
|
-
const accepted = withCells(fresh, { c1: cell("c1", "newer typing") });
|
|
253
|
-
settle(retried, accepted);
|
|
254
|
-
store.set(source, accepted);
|
|
255
|
-
store.set(source, withCells(accepted, { c1: cell("c1", "remote") }));
|
|
256
|
-
expect(store.get(cellAtom("c1"))?.code).toBe("remote");
|
|
257
|
-
},
|
|
258
|
-
);
|
|
259
|
-
|
|
260
164
|
it("invalidates submissions when switching notebooks, retaining each draft", () => {
|
|
261
165
|
const { store, source, editAtom, cellAtom, submit, settle } = setup();
|
|
262
166
|
store.set(editAtom, (tx) => tx.setCode("c1", "first"));
|
|
@@ -296,51 +200,4 @@ describe("document submissions", () => {
|
|
|
296
200
|
settle(sent, remote);
|
|
297
201
|
expect(store.set(submitAtom)).toBeNull();
|
|
298
202
|
});
|
|
299
|
-
|
|
300
|
-
it("does not treat a response for another notebook as acceptance", () => {
|
|
301
|
-
const { store, editAtom, settleAtom, submitAtom, submit } = setup();
|
|
302
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
303
|
-
const sent = submit();
|
|
304
|
-
expect(() => store.set(settleAtom, sent, document("n2").revision)).toThrow(
|
|
305
|
-
"another notebook",
|
|
306
|
-
);
|
|
307
|
-
expect(store.set(submitAtom)).toBeNull();
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
it("releases an unsent reversal without making a request", () => {
|
|
311
|
-
const { store, source, editAtom, cellAtom, submitAtom } = setup();
|
|
312
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
313
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "x = 1"));
|
|
314
|
-
expect(store.set(submitAtom)).toBeNull();
|
|
315
|
-
store.set(source, withCells(document(), { c1: cell("c1", "remote") }));
|
|
316
|
-
expect(store.get(cellAtom("c1"))?.code).toBe("remote");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("stops observing and accepting work after disposal", () => {
|
|
320
|
-
const {
|
|
321
|
-
store,
|
|
322
|
-
source,
|
|
323
|
-
editAtom,
|
|
324
|
-
orderAtom,
|
|
325
|
-
submitAtom,
|
|
326
|
-
submit,
|
|
327
|
-
settle,
|
|
328
|
-
abort,
|
|
329
|
-
} = setup();
|
|
330
|
-
store.set(editAtom, (tx) => tx.setCode("c1", "local"));
|
|
331
|
-
const sent = submit();
|
|
332
|
-
abort();
|
|
333
|
-
const onOrder = vi.fn();
|
|
334
|
-
store.sub(orderAtom, onOrder);
|
|
335
|
-
const confirmed = withCells(document(), { c1: cell("c1", "local") });
|
|
336
|
-
store.set(source, confirmed);
|
|
337
|
-
settle(sent, confirmed);
|
|
338
|
-
const edit = vi.fn();
|
|
339
|
-
store.set(editAtom, edit);
|
|
340
|
-
abort();
|
|
341
|
-
expect(edit).not.toHaveBeenCalled();
|
|
342
|
-
expect(onOrder).not.toHaveBeenCalled();
|
|
343
|
-
expect(store.get(orderAtom)).toBeNull();
|
|
344
|
-
expect(store.set(submitAtom)).toBeNull();
|
|
345
|
-
});
|
|
346
203
|
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import * as Protocol from "@marimo-team/client/protocol";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { initialDocument, protocolFixture } from "./protocol-fixtures";
|
|
6
|
+
import { createTestHost } from "./test-host";
|
|
7
|
+
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
|
|
10
|
+
it("matches the portable host examples for accepted and conflicting edits", () => {
|
|
11
|
+
const host = createTestHost(initialDocument());
|
|
12
|
+
const accepted = host.process({
|
|
13
|
+
notebookId: "n1",
|
|
14
|
+
idempotencyKey: "accepted",
|
|
15
|
+
changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
|
|
16
|
+
});
|
|
17
|
+
const expected = Protocol.EditResponse.parse(
|
|
18
|
+
protocolFixture("edit-response.json"),
|
|
19
|
+
);
|
|
20
|
+
expect(accepted.result).toEqual({ ok: true, status: 200, data: expected });
|
|
21
|
+
expect(accepted.update).toEqual(host.document());
|
|
22
|
+
|
|
23
|
+
const rejected = host.process({
|
|
24
|
+
notebookId: "n1",
|
|
25
|
+
idempotencyKey: "rejected",
|
|
26
|
+
changes: Protocol.EditRequest.parse(
|
|
27
|
+
protocolFixture("edit-conflict-request.json"),
|
|
28
|
+
).changes,
|
|
29
|
+
});
|
|
30
|
+
const problem = Protocol.Problem.parse(
|
|
31
|
+
protocolFixture("problems/edit-conflict.json"),
|
|
32
|
+
);
|
|
33
|
+
expect(rejected).toEqual({
|
|
34
|
+
result: {
|
|
35
|
+
ok: false,
|
|
36
|
+
status: 409,
|
|
37
|
+
problem: { ...problem, detail: expect.any(String) },
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
expect(host.document().revision).toEqual(expected.document);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("rejects an entire batch when a later precondition is stale", () => {
|
|
44
|
+
const initial = initialDocument();
|
|
45
|
+
const host = createTestHost(initial);
|
|
46
|
+
const result = host.apply([
|
|
47
|
+
{ kind: "put_cell", base_etag: null, cell: { id: "created", code: "new" } },
|
|
48
|
+
{ kind: "delete_cell", id: "c3", base_etag: "stale" },
|
|
49
|
+
]);
|
|
50
|
+
expect(result).toMatchObject({
|
|
51
|
+
result: { ok: false, status: 409, problem: { edit: { change: 1 } } },
|
|
52
|
+
});
|
|
53
|
+
expect(host.document()).toEqual(initial);
|
|
54
|
+
expect(result.update).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps successful and rejected receipts tied to their original payload and revision", () => {
|
|
58
|
+
vi.useFakeTimers({ now: 0 });
|
|
59
|
+
const host = createTestHost(initialDocument());
|
|
60
|
+
const request = {
|
|
61
|
+
notebookId: "n1",
|
|
62
|
+
idempotencyKey: "original",
|
|
63
|
+
changes: Protocol.EditRequest.parse(protocolFixture("edit.json")).changes,
|
|
64
|
+
};
|
|
65
|
+
const accepted = host.process(request);
|
|
66
|
+
const stale = {
|
|
67
|
+
notebookId: "n1",
|
|
68
|
+
idempotencyKey: "stale",
|
|
69
|
+
changes: Protocol.EditRequest.parse(
|
|
70
|
+
protocolFixture("edit-conflict-request.json"),
|
|
71
|
+
).changes,
|
|
72
|
+
};
|
|
73
|
+
const rejected = host.process(stale);
|
|
74
|
+
host.apply([
|
|
75
|
+
{ kind: "put_cell", base_etag: "*", cell: { id: "c3", code: "later" } },
|
|
76
|
+
]);
|
|
77
|
+
const latest = host.document();
|
|
78
|
+
expect(host.process(request)).toEqual({ result: accepted.result });
|
|
79
|
+
expect(host.process(stale)).toEqual({ result: rejected.result });
|
|
80
|
+
expect(host.process({ ...request, changes: [] }).result).toMatchObject({
|
|
81
|
+
ok: false,
|
|
82
|
+
status: 409,
|
|
83
|
+
});
|
|
84
|
+
expect(host.document()).toEqual(latest);
|
|
85
|
+
|
|
86
|
+
vi.advanceTimersByTime(300_000);
|
|
87
|
+
expect(host.process(request).result).toMatchObject({
|
|
88
|
+
ok: false,
|
|
89
|
+
status: 409,
|
|
90
|
+
});
|
|
91
|
+
expect(host.document()).toEqual(latest);
|
|
92
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { Result } from "@marimo-team/client";
|
|
5
|
+
import * as Protocol from "@marimo-team/client/protocol";
|
|
6
|
+
import type { Document } from "../../host/resources/document";
|
|
7
|
+
|
|
8
|
+
export interface EditRequest {
|
|
9
|
+
readonly notebookId: Protocol.NotebookId;
|
|
10
|
+
readonly changes: readonly Protocol.Change[];
|
|
11
|
+
readonly idempotencyKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Outcome {
|
|
15
|
+
readonly result: Result<Protocol.EditResponseOutput>;
|
|
16
|
+
/** Only a new commit produces a stream update; replaying a receipt does not. */
|
|
17
|
+
readonly update?: Document;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const hash = (text: string) =>
|
|
21
|
+
createHash("sha256").update(text).digest("hex").slice(0, 16);
|
|
22
|
+
|
|
23
|
+
function record(
|
|
24
|
+
input: Protocol.CellInput,
|
|
25
|
+
notebookId: Protocol.NotebookId,
|
|
26
|
+
version: number,
|
|
27
|
+
): Protocol.Cell {
|
|
28
|
+
const cell = Protocol.CellInput.parse(input);
|
|
29
|
+
const config = Protocol.CellConfigInput.parse(cell.config ?? {});
|
|
30
|
+
const code = cell.code.replace(/\r\n?/g, "\n");
|
|
31
|
+
const codeHash = hash(code);
|
|
32
|
+
return {
|
|
33
|
+
id: cell.id,
|
|
34
|
+
notebook_id: notebookId,
|
|
35
|
+
name: cell.name,
|
|
36
|
+
code,
|
|
37
|
+
config,
|
|
38
|
+
code_hash: codeHash,
|
|
39
|
+
etag: hash(
|
|
40
|
+
[
|
|
41
|
+
codeHash,
|
|
42
|
+
cell.name,
|
|
43
|
+
...[
|
|
44
|
+
config.column,
|
|
45
|
+
config.disabled,
|
|
46
|
+
config.hide_code,
|
|
47
|
+
config.expand_output,
|
|
48
|
+
].map((value) => JSON.stringify(value)),
|
|
49
|
+
].join("\n"),
|
|
50
|
+
),
|
|
51
|
+
version,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rejected(
|
|
56
|
+
status: number,
|
|
57
|
+
kind: string,
|
|
58
|
+
detail: string,
|
|
59
|
+
edit?: Protocol.Problem["edit"],
|
|
60
|
+
): Outcome {
|
|
61
|
+
return {
|
|
62
|
+
result: {
|
|
63
|
+
ok: false,
|
|
64
|
+
status,
|
|
65
|
+
problem: {
|
|
66
|
+
type: `tag:marimo.io,2026:host/${kind}`,
|
|
67
|
+
title: kind === "conflict" ? "Conflict" : "Invalid request",
|
|
68
|
+
detail,
|
|
69
|
+
status,
|
|
70
|
+
...(edit === undefined ? {} : { edit }),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** An independent implementation of the document edit protocol, without client draft logic. */
|
|
77
|
+
export function createTestHost(initial: Document) {
|
|
78
|
+
let current = initial;
|
|
79
|
+
const receipts = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{
|
|
82
|
+
fingerprint: string;
|
|
83
|
+
result: Result<Protocol.EditResponseOutput>;
|
|
84
|
+
expiresAt: number;
|
|
85
|
+
}
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
function apply(changes: readonly Protocol.Change[]): Outcome {
|
|
89
|
+
const cells = new Map(current.cells);
|
|
90
|
+
let order = [...current.revision.order];
|
|
91
|
+
const touched = new Set<Protocol.CellId>();
|
|
92
|
+
const version = current.revision.version + 1;
|
|
93
|
+
const matches = (id: Protocol.CellId, etag: string) =>
|
|
94
|
+
etag === "*" || current.cells.get(id)?.etag === etag;
|
|
95
|
+
const conflict = (change: number, id?: Protocol.CellId) =>
|
|
96
|
+
rejected(409, "conflict", `Edit conflict at change ${change}`, {
|
|
97
|
+
change,
|
|
98
|
+
cell: id === undefined ? null : (current.cells.get(id) ?? null),
|
|
99
|
+
document: current.revision,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
for (const [index, change] of changes.entries()) {
|
|
103
|
+
switch (change.kind) {
|
|
104
|
+
case "put_cell": {
|
|
105
|
+
const { id } = change.cell;
|
|
106
|
+
if (change.base_etag === null) {
|
|
107
|
+
if (current.cells.has(id) || cells.has(id)) {
|
|
108
|
+
return conflict(index, id);
|
|
109
|
+
}
|
|
110
|
+
if (change.after !== undefined && change.before !== undefined) {
|
|
111
|
+
return rejected(
|
|
112
|
+
400,
|
|
113
|
+
"invalid-request",
|
|
114
|
+
"A cell cannot name both before and after",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const anchor = change.after ?? change.before;
|
|
118
|
+
if (anchor !== undefined && !order.includes(anchor)) {
|
|
119
|
+
return rejected(
|
|
120
|
+
400,
|
|
121
|
+
"invalid-request",
|
|
122
|
+
"The placement anchor does not exist",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const position =
|
|
126
|
+
anchor === undefined
|
|
127
|
+
? order.length
|
|
128
|
+
: order.indexOf(anchor) + (change.after === undefined ? 0 : 1);
|
|
129
|
+
order.splice(position, 0, id);
|
|
130
|
+
} else if (!cells.has(id) || !matches(id, change.base_etag)) {
|
|
131
|
+
return conflict(index, id);
|
|
132
|
+
}
|
|
133
|
+
cells.set(
|
|
134
|
+
id,
|
|
135
|
+
record(change.cell, current.revision.notebook_id, version),
|
|
136
|
+
);
|
|
137
|
+
touched.add(id);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "delete_cell":
|
|
141
|
+
if (!cells.has(change.id) || !matches(change.id, change.base_etag)) {
|
|
142
|
+
return conflict(index, change.id);
|
|
143
|
+
}
|
|
144
|
+
cells.delete(change.id);
|
|
145
|
+
order = order.filter((id) => id !== change.id);
|
|
146
|
+
touched.add(change.id);
|
|
147
|
+
break;
|
|
148
|
+
case "set_order":
|
|
149
|
+
if (
|
|
150
|
+
change.base_etag !== "*" &&
|
|
151
|
+
change.base_etag !== current.revision.order_etag
|
|
152
|
+
) {
|
|
153
|
+
return conflict(index);
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
change.order.length !== cells.size ||
|
|
157
|
+
new Set(change.order).size !== cells.size ||
|
|
158
|
+
change.order.some((id) => !cells.has(id))
|
|
159
|
+
) {
|
|
160
|
+
return rejected(
|
|
161
|
+
400,
|
|
162
|
+
"invalid-request",
|
|
163
|
+
"Order must name every cell exactly once",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
order = [...change.order];
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const [id, cell] of cells) {
|
|
172
|
+
const previous = current.cells.get(id);
|
|
173
|
+
if (previous?.etag === cell.etag) {
|
|
174
|
+
cells.set(id, previous);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const orderEtag = hash(JSON.stringify(order));
|
|
178
|
+
const etag = hash(
|
|
179
|
+
[
|
|
180
|
+
orderEtag,
|
|
181
|
+
current.revision.app_config_etag,
|
|
182
|
+
...order.map((id) => cells.get(id)!.etag),
|
|
183
|
+
].join("\n"),
|
|
184
|
+
);
|
|
185
|
+
const changed = etag !== current.revision.etag;
|
|
186
|
+
if (changed) {
|
|
187
|
+
current = {
|
|
188
|
+
revision: {
|
|
189
|
+
...current.revision,
|
|
190
|
+
order,
|
|
191
|
+
order_etag: orderEtag,
|
|
192
|
+
etag,
|
|
193
|
+
version,
|
|
194
|
+
},
|
|
195
|
+
cells,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const versions: Protocol.EditResponseOutput["cells"] = {};
|
|
199
|
+
for (const id of touched) {
|
|
200
|
+
const cell = current.cells.get(id);
|
|
201
|
+
versions[id] =
|
|
202
|
+
cell === undefined
|
|
203
|
+
? null
|
|
204
|
+
: {
|
|
205
|
+
etag: cell.etag,
|
|
206
|
+
code_hash: cell.code_hash,
|
|
207
|
+
version: cell.version,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
result: {
|
|
212
|
+
ok: true,
|
|
213
|
+
status: 200,
|
|
214
|
+
data: { document: current.revision, cells: versions },
|
|
215
|
+
},
|
|
216
|
+
...(changed ? { update: current } : {}),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
document: () => current,
|
|
222
|
+
apply,
|
|
223
|
+
process(request: EditRequest): Outcome {
|
|
224
|
+
const fingerprint = JSON.stringify([request.notebookId, request.changes]);
|
|
225
|
+
const receipt = receipts.get(request.idempotencyKey);
|
|
226
|
+
if (receipt !== undefined && Date.now() < receipt.expiresAt) {
|
|
227
|
+
return receipt.fingerprint === fingerprint
|
|
228
|
+
? { result: receipt.result }
|
|
229
|
+
: rejected(
|
|
230
|
+
409,
|
|
231
|
+
"conflict",
|
|
232
|
+
"Idempotency key was used for another request",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const outcome =
|
|
236
|
+
request.notebookId === current.revision.notebook_id
|
|
237
|
+
? apply(request.changes)
|
|
238
|
+
: rejected(404, "not-found", "Notebook does not exist");
|
|
239
|
+
receipts.set(request.idempotencyKey, {
|
|
240
|
+
fingerprint,
|
|
241
|
+
result: outcome.result,
|
|
242
|
+
expiresAt: Date.now() + 300_000,
|
|
243
|
+
});
|
|
244
|
+
return outcome;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|