duet-mcp 0.6.1
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/LICENSE +21 -0
- package/README.md +367 -0
- package/doc/README.jp.md +347 -0
- package/lib/blob.d.ts +16 -0
- package/lib/blob.js +57 -0
- package/lib/boot.d.ts +2 -0
- package/lib/boot.js +134 -0
- package/lib/client-store.d.ts +28 -0
- package/lib/client-store.js +147 -0
- package/lib/client.d.ts +25 -0
- package/lib/client.js +58 -0
- package/lib/diff.d.ts +27 -0
- package/lib/diff.js +103 -0
- package/lib/doc.d.ts +30 -0
- package/lib/doc.js +221 -0
- package/lib/edit.d.ts +25 -0
- package/lib/edit.js +63 -0
- package/lib/http.d.ts +9 -0
- package/lib/http.js +151 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +2 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +109 -0
- package/lib/op.d.ts +10 -0
- package/lib/op.js +19 -0
- package/lib/paths.d.ts +4 -0
- package/lib/paths.js +9 -0
- package/lib/protocol.d.ts +36 -0
- package/lib/protocol.js +10 -0
- package/lib/server.d.ts +1 -0
- package/lib/server.js +1 -0
- package/lib/shot.d.ts +8 -0
- package/lib/shot.js +85 -0
- package/lib/transport.d.ts +7 -0
- package/lib/transport.js +22 -0
- package/lib/types.d.ts +66 -0
- package/lib/types.js +1 -0
- package/lib/wire.d.ts +14 -0
- package/lib/wire.js +42 -0
- package/package.json +97 -0
- package/template/app.ts +17 -0
- package/template/doc.ts +18 -0
- package/template/main.ts +8 -0
- package/template/ops.ts +42 -0
- package/template/start.ts +4 -0
- package/template/ui/canvas.tsx +91 -0
- package/template/ui/card-editing.tsx +48 -0
- package/template/ui/edit-actions.tsx +30 -0
- package/template/ui/index.html +15 -0
- package/template/ui/main.tsx +53 -0
- package/template/ui/style.css +15 -0
- package/template/ui/tsconfig.json +15 -0
- package/template/ui/vite.config.ts +24 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { parseRevision } from "./protocol.js";
|
|
2
|
+
import { WAIT_MS } from "./wire.js";
|
|
3
|
+
function snapshot(value) {
|
|
4
|
+
const s = value;
|
|
5
|
+
if (!s || !parseRevision(s.revision) || !("doc" in s) || typeof s.actor !== "string" ||
|
|
6
|
+
!s.activity || typeof s.activity !== "object")
|
|
7
|
+
throw new Error("snapshot の形ではない。GUI と daemon を同時に更新すること。");
|
|
8
|
+
return s;
|
|
9
|
+
}
|
|
10
|
+
/** ブラウザで一つ共有する購読。React に依存せず応答の順序を管理する。 */
|
|
11
|
+
export class ClientStore {
|
|
12
|
+
request;
|
|
13
|
+
current = null;
|
|
14
|
+
listeners = new Set();
|
|
15
|
+
running = false;
|
|
16
|
+
ctrl = null;
|
|
17
|
+
polling = null;
|
|
18
|
+
generation = 0;
|
|
19
|
+
pollVersion = 0;
|
|
20
|
+
force = false;
|
|
21
|
+
received = 0;
|
|
22
|
+
refreshWaiters = [];
|
|
23
|
+
constructor(request = (...args) => fetch(...args)) {
|
|
24
|
+
this.request = request;
|
|
25
|
+
}
|
|
26
|
+
getSnapshot = () => this.current;
|
|
27
|
+
get receivedAt() { return this.received; }
|
|
28
|
+
subscribe = (listener) => {
|
|
29
|
+
this.listeners.add(listener);
|
|
30
|
+
this.running = true;
|
|
31
|
+
this.start();
|
|
32
|
+
return () => {
|
|
33
|
+
this.listeners.delete(listener);
|
|
34
|
+
if (!this.listeners.size) {
|
|
35
|
+
this.running = false;
|
|
36
|
+
this.pollVersion++;
|
|
37
|
+
this.ctrl?.abort();
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
refresh = () => {
|
|
42
|
+
const done = new Promise((resolve) => this.refreshWaiters.push(resolve));
|
|
43
|
+
this.force = true;
|
|
44
|
+
this.pollVersion++;
|
|
45
|
+
this.ctrl?.abort();
|
|
46
|
+
this.start();
|
|
47
|
+
return done;
|
|
48
|
+
};
|
|
49
|
+
emit() { for (const listener of this.listeners)
|
|
50
|
+
listener(); }
|
|
51
|
+
start() {
|
|
52
|
+
if (!this.running || this.polling)
|
|
53
|
+
return;
|
|
54
|
+
this.polling = this.loop().finally(() => {
|
|
55
|
+
this.polling = null;
|
|
56
|
+
if (this.running)
|
|
57
|
+
this.start();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
accept(s, authoritative) {
|
|
61
|
+
const incoming = parseRevision(s.revision);
|
|
62
|
+
const old = this.current && parseRevision(this.current.revision);
|
|
63
|
+
if (old && incoming.epoch !== old.epoch) {
|
|
64
|
+
if (!authoritative) {
|
|
65
|
+
this.refresh();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.generation++;
|
|
69
|
+
}
|
|
70
|
+
else if (old && incoming.seq < old.seq) {
|
|
71
|
+
// この取得より新しい完全な snapshot を既に受信している。
|
|
72
|
+
if (authoritative)
|
|
73
|
+
for (const resolve of this.refreshWaiters.splice(0))
|
|
74
|
+
resolve();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.received = Date.now();
|
|
78
|
+
const generation = this.generation;
|
|
79
|
+
this.current = {
|
|
80
|
+
...s,
|
|
81
|
+
// 固定した snapshot の版を送る。送信時の current は使わない。
|
|
82
|
+
run: (name, args = {}) => this.run(s.revision, generation, name, args),
|
|
83
|
+
};
|
|
84
|
+
this.emit();
|
|
85
|
+
if (authoritative)
|
|
86
|
+
for (const resolve of this.refreshWaiters.splice(0))
|
|
87
|
+
resolve();
|
|
88
|
+
}
|
|
89
|
+
async run(revision, generation, name, args) {
|
|
90
|
+
let result;
|
|
91
|
+
try {
|
|
92
|
+
const res = await this.request(`/api/op/${encodeURIComponent(name)}`, {
|
|
93
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ ...args, baseRevision: revision }),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok)
|
|
97
|
+
throw new Error(`HTTP ${res.status}`);
|
|
98
|
+
const raw = await res.json();
|
|
99
|
+
const s = snapshot(raw);
|
|
100
|
+
if (!("ok" in s) && !("rejected" in s) && !("conflict" in s))
|
|
101
|
+
throw new Error("操作応答の形ではない");
|
|
102
|
+
result = s;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
this.refresh();
|
|
106
|
+
throw new Error("操作の結果を確認できない。自動再送していない。現在値を再取得して確認すること。");
|
|
107
|
+
}
|
|
108
|
+
if (generation !== this.generation) {
|
|
109
|
+
throw new Error("応答を待つ間に daemon が交代した。現在値を確認すること。");
|
|
110
|
+
}
|
|
111
|
+
this.accept(result, false);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
async loop() {
|
|
115
|
+
while (this.running) {
|
|
116
|
+
this.ctrl = new AbortController();
|
|
117
|
+
const version = this.pollVersion;
|
|
118
|
+
const since = this.force ? undefined : this.current?.revision;
|
|
119
|
+
this.force = false;
|
|
120
|
+
try {
|
|
121
|
+
const q = since === undefined ? "" : `?since=${encodeURIComponent(since)}&timeout=${WAIT_MS}`;
|
|
122
|
+
const res = await this.request(`/api/doc${q}`, { signal: this.ctrl.signal });
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
throw new Error(`HTTP ${res.status}`);
|
|
125
|
+
const s = snapshot(await res.json());
|
|
126
|
+
if (!this.running || version !== this.pollVersion)
|
|
127
|
+
continue;
|
|
128
|
+
this.accept(s, true);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
if (!this.running)
|
|
132
|
+
break;
|
|
133
|
+
if (version !== this.pollVersion)
|
|
134
|
+
continue;
|
|
135
|
+
// 再接続待ちも abort できるようにする。
|
|
136
|
+
const signal = this.ctrl.signal;
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); };
|
|
139
|
+
const timer = setTimeout(done, 500);
|
|
140
|
+
signal.addEventListener("abort", done, { once: true });
|
|
141
|
+
if (signal.aborted)
|
|
142
|
+
done();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Observed } from "./client-store.js";
|
|
2
|
+
export type { Observed } from "./client-store.js";
|
|
3
|
+
export type { Snapshot, RunResult, Revision, Change } from "./protocol.js";
|
|
4
|
+
export declare const refreshDoc: () => Promise<void>;
|
|
5
|
+
export declare function touch(): void;
|
|
6
|
+
export declare const blobUrl: (id: string) => string;
|
|
7
|
+
export declare function uploadBlob(file: Blob): Promise<{
|
|
8
|
+
id: string;
|
|
9
|
+
mime: string;
|
|
10
|
+
size: number;
|
|
11
|
+
}>;
|
|
12
|
+
export declare function useDoc<Doc>(): Observed<Doc> | null;
|
|
13
|
+
export declare function useEdit<Value>(): {
|
|
14
|
+
begin: (base: Observed<unknown>, value: Value) => void;
|
|
15
|
+
restart: (base: Observed<unknown>, value: Value) => void;
|
|
16
|
+
setValue: (value: Value) => void;
|
|
17
|
+
cancel: () => void;
|
|
18
|
+
run: (name: string, args?: Record<string, unknown>) => Promise<import("./protocol.js").RunResult<unknown>>;
|
|
19
|
+
active: boolean;
|
|
20
|
+
value: Value | undefined;
|
|
21
|
+
pending: boolean;
|
|
22
|
+
result: import("./protocol.js").RunResult<unknown> | null;
|
|
23
|
+
error: string | null;
|
|
24
|
+
};
|
|
25
|
+
export { EditSession } from "./edit.js";
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { ClientStore } from "./client-store.js";
|
|
3
|
+
import { EditSession } from "./edit.js";
|
|
4
|
+
const store = new ClientStore();
|
|
5
|
+
export const refreshDoc = store.refresh;
|
|
6
|
+
export function touch() { void fetch("/api/touch", { method: "POST" }).catch(() => { }); }
|
|
7
|
+
export const blobUrl = (id) => `/blob/${id}`;
|
|
8
|
+
export async function uploadBlob(file) {
|
|
9
|
+
const res = await fetch("/api/blob", {
|
|
10
|
+
method: "POST", headers: { "content-type": file.type || "application/octet-stream" }, body: file,
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error(`uploadBlob: HTTP ${res.status}`);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
// useDoc が複数でも活動リスナは一組だけ。
|
|
17
|
+
let activityUsers = 0;
|
|
18
|
+
let lastTouch = 0;
|
|
19
|
+
const onActivity = () => {
|
|
20
|
+
if (Date.now() - lastTouch < 1000)
|
|
21
|
+
return;
|
|
22
|
+
lastTouch = Date.now();
|
|
23
|
+
touch();
|
|
24
|
+
};
|
|
25
|
+
export function useDoc() {
|
|
26
|
+
const raw = useSyncExternalStore(store.subscribe, store.getSnapshot, () => null);
|
|
27
|
+
const [now, setNow] = useState(Date.now);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
30
|
+
if (activityUsers++ === 0) {
|
|
31
|
+
window.addEventListener("pointerdown", onActivity, { capture: true, passive: true });
|
|
32
|
+
window.addEventListener("keydown", onActivity, { capture: true, passive: true });
|
|
33
|
+
}
|
|
34
|
+
return () => {
|
|
35
|
+
clearInterval(timer);
|
|
36
|
+
if (--activityUsers === 0) {
|
|
37
|
+
window.removeEventListener("pointerdown", onActivity, true);
|
|
38
|
+
window.removeEventListener("keydown", onActivity, true);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
// 受信時ではなく、この snapshot を描いた DOM の commit 後に進める。
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
if (raw)
|
|
45
|
+
document.documentElement.dataset.duetRevision = raw.revision;
|
|
46
|
+
}, [raw]);
|
|
47
|
+
return useMemo(() => raw && ({
|
|
48
|
+
...raw,
|
|
49
|
+
activity: Object.fromEntries(Object.entries(raw.activity).map(([who, ms]) => [who, ms + Math.max(0, now - store.receivedAt)])),
|
|
50
|
+
}), [raw, now]);
|
|
51
|
+
}
|
|
52
|
+
export function useEdit() {
|
|
53
|
+
const [session] = useState(() => new EditSession());
|
|
54
|
+
const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
|
|
55
|
+
return { ...state, begin: session.begin, restart: session.restart, setValue: session.setValue,
|
|
56
|
+
cancel: session.cancel, run: session.run };
|
|
57
|
+
}
|
|
58
|
+
export { EditSession } from "./edit.js";
|
package/lib/diff.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc の構造差分。
|
|
3
|
+
*
|
|
4
|
+
* 変更検知(差分が空か)と変更説明(どこを触ったか)を、この 1 箇所から得る。
|
|
5
|
+
* 文字列比較をやめたので、キーの並びが変わっただけで revision が進むことがない。
|
|
6
|
+
*
|
|
7
|
+
* パスは JSON Pointer(RFC 6901)。root は ""、以下は "/cards/3/title"。
|
|
8
|
+
* 標準の書式なので、そのまま LLM への応答に載せて読ませられる。
|
|
9
|
+
*/
|
|
10
|
+
/** doc に置ける値。開発者が明示したいときに使う(強制はしない)。 */
|
|
11
|
+
export type Json = string | number | boolean | null | Json[] | {
|
|
12
|
+
[k: string]: Json;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* JSON で表せない値を、書かれた瞬間に見つける。
|
|
16
|
+
*
|
|
17
|
+
* structuredClone は Map / Set / Date を保つが JSON.stringify は保たない。
|
|
18
|
+
* 検査しないと、Map を置いたアプリは「コミットしたのに何も起きない」あるいは
|
|
19
|
+
* 「毎回 revision が進む」という、原因の分からない壊れ方をする。
|
|
20
|
+
* 型で縛るより、実際に置かれた場所を名指しで言う方が短く終わる。
|
|
21
|
+
*/
|
|
22
|
+
export declare function assertJson(value: unknown, path?: string, seen?: Set<object>): void;
|
|
23
|
+
/**
|
|
24
|
+
* before から after で変わった場所。空なら何も変わっていない。
|
|
25
|
+
* 深い方から葉のパスだけを返す("/cards/3/title" は返るが "/cards" は返らない)。
|
|
26
|
+
*/
|
|
27
|
+
export declare function changes(before: unknown, after: unknown): string[];
|
package/lib/diff.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doc の構造差分。
|
|
3
|
+
*
|
|
4
|
+
* 変更検知(差分が空か)と変更説明(どこを触ったか)を、この 1 箇所から得る。
|
|
5
|
+
* 文字列比較をやめたので、キーの並びが変わっただけで revision が進むことがない。
|
|
6
|
+
*
|
|
7
|
+
* パスは JSON Pointer(RFC 6901)。root は ""、以下は "/cards/3/title"。
|
|
8
|
+
* 標準の書式なので、そのまま LLM への応答に載せて読ませられる。
|
|
9
|
+
*/
|
|
10
|
+
const isPlain = (v) => {
|
|
11
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
12
|
+
return false;
|
|
13
|
+
const proto = Object.getPrototypeOf(v);
|
|
14
|
+
return proto === Object.prototype || proto === null;
|
|
15
|
+
};
|
|
16
|
+
const seg = (key) => `/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`;
|
|
17
|
+
const at = (path) => path || "(root)";
|
|
18
|
+
/**
|
|
19
|
+
* JSON で表せない値を、書かれた瞬間に見つける。
|
|
20
|
+
*
|
|
21
|
+
* structuredClone は Map / Set / Date を保つが JSON.stringify は保たない。
|
|
22
|
+
* 検査しないと、Map を置いたアプリは「コミットしたのに何も起きない」あるいは
|
|
23
|
+
* 「毎回 revision が進む」という、原因の分からない壊れ方をする。
|
|
24
|
+
* 型で縛るより、実際に置かれた場所を名指しで言う方が短く終わる。
|
|
25
|
+
*/
|
|
26
|
+
export function assertJson(value, path = "", seen = new Set()) {
|
|
27
|
+
if (value !== null && typeof value === "object") {
|
|
28
|
+
if (seen.has(value))
|
|
29
|
+
throw new Error(`JSON の ${at(path)} が循環参照。`);
|
|
30
|
+
seen.add(value);
|
|
31
|
+
}
|
|
32
|
+
if (value === null)
|
|
33
|
+
return;
|
|
34
|
+
switch (typeof value) {
|
|
35
|
+
case "string":
|
|
36
|
+
case "boolean":
|
|
37
|
+
return;
|
|
38
|
+
case "number":
|
|
39
|
+
// NaN / Infinity は JSON では null になる。黙って値が変わるので弾く。
|
|
40
|
+
if (!Number.isFinite(value)) {
|
|
41
|
+
throw new Error(`doc の ${at(path)} が ${String(value)}。JSON では null になるので置けない。`);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
case "undefined":
|
|
45
|
+
throw new Error(`doc の ${at(path)} が undefined。JSON では消えるので、` +
|
|
46
|
+
`キーを消すなら delete、値を空にするなら null を使うこと。`);
|
|
47
|
+
default:
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
for (let i = 0; i < value.length; i++)
|
|
52
|
+
assertJson(value[i], path + seg(i), seen);
|
|
53
|
+
seen.delete(value);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (isPlain(value)) {
|
|
57
|
+
for (const [k, v] of Object.entries(value))
|
|
58
|
+
assertJson(v, path + seg(k), seen);
|
|
59
|
+
seen.delete(value);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const name = value.constructor?.name ?? typeof value;
|
|
63
|
+
throw new Error(`doc の ${at(path)} が ${name}。JSON で表せる値(object / array / string / number / boolean / null)だけ置けること。`);
|
|
64
|
+
}
|
|
65
|
+
/** union。before の並びを保ったまま、after で増えたキーを後ろに足す。 */
|
|
66
|
+
function keysOf(a, b) {
|
|
67
|
+
const out = Object.keys(a);
|
|
68
|
+
for (const k of Object.keys(b))
|
|
69
|
+
if (!Object.hasOwn(a, k))
|
|
70
|
+
out.push(k);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function walk(a, b, path, out) {
|
|
74
|
+
if (a === b)
|
|
75
|
+
return;
|
|
76
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
77
|
+
// 長さが違えば要素の対応が付かない。配列ごと触ったことにする。
|
|
78
|
+
// 変更説明では配列全体を指す。競合判定には使わない。
|
|
79
|
+
if (a.length !== b.length) {
|
|
80
|
+
out.push(path);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (let i = 0; i < a.length; i += 1)
|
|
84
|
+
walk(a[i], b[i], path + seg(i), out);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (isPlain(a) && isPlain(b)) {
|
|
88
|
+
for (const k of keysOf(a, b))
|
|
89
|
+
walk(a[k], b[k], path + seg(k), out);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// プリミティブ、型が変わった、片方が null。ここが葉になる。
|
|
93
|
+
out.push(path);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* before から after で変わった場所。空なら何も変わっていない。
|
|
97
|
+
* 深い方から葉のパスだけを返す("/cards/3/title" は返るが "/cards" は返らない)。
|
|
98
|
+
*/
|
|
99
|
+
export function changes(before, after) {
|
|
100
|
+
const out = [];
|
|
101
|
+
walk(before, after, "", out);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
package/lib/doc.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Revision, type Snapshot, type RunResult, type WaitResult } from "./protocol.js";
|
|
2
|
+
import type { Actor, AppDef } from "./types.js";
|
|
3
|
+
export type { RunResult, WaitResult, Diff, Change } from "./protocol.js";
|
|
4
|
+
/** snapshot が確定状態。ログは説明用であり、操作の可否に使わない。 */
|
|
5
|
+
export declare class DocStore<Doc> {
|
|
6
|
+
private readonly app;
|
|
7
|
+
private readonly dataDir;
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly logFile: string;
|
|
10
|
+
private readonly epoch;
|
|
11
|
+
private seq;
|
|
12
|
+
private oldest;
|
|
13
|
+
private state;
|
|
14
|
+
private readonly schemas;
|
|
15
|
+
private readonly waiters;
|
|
16
|
+
private readonly seen;
|
|
17
|
+
private log;
|
|
18
|
+
constructor(app: AppDef<Doc>, dataDir?: string);
|
|
19
|
+
get revision(): Revision;
|
|
20
|
+
private load;
|
|
21
|
+
private persist;
|
|
22
|
+
private appendLog;
|
|
23
|
+
touch(actor: Actor): void;
|
|
24
|
+
snapshot(actor: Actor): Snapshot<Doc>;
|
|
25
|
+
run(name: string, args: unknown, actor: Actor): RunResult<Doc>;
|
|
26
|
+
private position;
|
|
27
|
+
private collect;
|
|
28
|
+
private diff;
|
|
29
|
+
wait(since: Revision | undefined, until: string[] | undefined, timeoutMs: number, actor: Actor, signal?: AbortSignal): Promise<WaitResult<Doc>>;
|
|
30
|
+
}
|
package/lib/doc.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { assertJson, changes } from "./diff.js";
|
|
6
|
+
import { inputShape } from "./op.js";
|
|
7
|
+
import { dataDirFor } from "./paths.js";
|
|
8
|
+
import { parseRevision } from "./protocol.js";
|
|
9
|
+
const LOG_LIMIT = 1000;
|
|
10
|
+
const CHANGE_LIMIT = 100;
|
|
11
|
+
const CHANGE_BYTES = 32 * 1024;
|
|
12
|
+
const LOG_FILE_MAX = 4 * 1024 * 1024;
|
|
13
|
+
class Rejection extends Error {
|
|
14
|
+
}
|
|
15
|
+
/** snapshot が確定状態。ログは説明用であり、操作の可否に使わない。 */
|
|
16
|
+
export class DocStore {
|
|
17
|
+
app;
|
|
18
|
+
dataDir;
|
|
19
|
+
file;
|
|
20
|
+
logFile;
|
|
21
|
+
epoch = randomUUID();
|
|
22
|
+
seq = 0;
|
|
23
|
+
oldest = 0;
|
|
24
|
+
state;
|
|
25
|
+
schemas = new Map();
|
|
26
|
+
waiters = new Set();
|
|
27
|
+
seen = new Map();
|
|
28
|
+
log = [];
|
|
29
|
+
constructor(app, dataDir = dataDirFor(app)) {
|
|
30
|
+
this.app = app;
|
|
31
|
+
this.dataDir = dataDir;
|
|
32
|
+
this.file = path.join(dataDir, `${app.id}.json`);
|
|
33
|
+
this.logFile = path.join(dataDir, `${app.id}.log`);
|
|
34
|
+
this.state = app.initialDoc();
|
|
35
|
+
assertJson(this.state);
|
|
36
|
+
this.load();
|
|
37
|
+
this.oldest = this.seq;
|
|
38
|
+
for (const op of app.ops) {
|
|
39
|
+
if (this.schemas.has(op.name))
|
|
40
|
+
throw new Error(`duplicate op: ${op.name}`);
|
|
41
|
+
this.schemas.set(op.name, z.object(inputShape(op)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
get revision() { return `${this.epoch}:${this.seq}`; }
|
|
45
|
+
load() {
|
|
46
|
+
if (!fs.existsSync(this.file))
|
|
47
|
+
return;
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
51
|
+
if (!raw || !Number.isSafeInteger(raw.revision) || raw.revision < 0 || !("doc" in raw)) {
|
|
52
|
+
throw new Error("snapshot の形ではない");
|
|
53
|
+
}
|
|
54
|
+
assertJson(raw.doc);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const stamp = Date.now();
|
|
58
|
+
// 退避できなければ起動を止める。元ファイルを初期値で潰さない。
|
|
59
|
+
fs.renameSync(this.file, `${this.file}.corrupt.${stamp}`);
|
|
60
|
+
if (fs.existsSync(this.logFile))
|
|
61
|
+
fs.renameSync(this.logFile, `${this.logFile}.corrupt.${stamp}`);
|
|
62
|
+
console.error("[duet] snapshot を退避した", err);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (raw.version !== undefined && raw.version !== this.app.version) {
|
|
66
|
+
const backup = `${this.file}.version.${Date.now()}.${randomUUID()}.bak`;
|
|
67
|
+
fs.copyFileSync(this.file, backup);
|
|
68
|
+
console.error(`[duet] version ${raw.version} → ${this.app.version}。移行が必要なら ${backup} を使う。`);
|
|
69
|
+
}
|
|
70
|
+
this.seq = raw.revision;
|
|
71
|
+
this.state = raw.doc;
|
|
72
|
+
}
|
|
73
|
+
persist(seq, doc) {
|
|
74
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
75
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
76
|
+
try {
|
|
77
|
+
fs.writeFileSync(tmp, JSON.stringify({ revision: seq, version: this.app.version, doc }));
|
|
78
|
+
fs.renameSync(tmp, this.file);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
try {
|
|
82
|
+
fs.unlinkSync(tmp);
|
|
83
|
+
}
|
|
84
|
+
catch { /* 保存前の失敗。元ファイルは残す。 */ }
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
appendLog(event) {
|
|
89
|
+
try {
|
|
90
|
+
if (fs.existsSync(this.logFile) && fs.statSync(this.logFile).size > LOG_FILE_MAX) {
|
|
91
|
+
fs.renameSync(this.logFile, `${this.logFile}.1`);
|
|
92
|
+
}
|
|
93
|
+
fs.appendFileSync(this.logFile, `${JSON.stringify(event)}\n`);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// snapshot は確定済み。ログの失敗を操作の失敗に変えない。
|
|
97
|
+
console.error("[duet] 操作は保存済み。補助ログを書けなかった。", err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
touch(actor) { this.seen.set(actor, Date.now()); }
|
|
101
|
+
snapshot(actor) {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
return {
|
|
104
|
+
revision: this.revision, actor, doc: structuredClone(this.state),
|
|
105
|
+
activity: Object.fromEntries([...this.seen].map(([who, time]) => [who, now - time])),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
run(name, args, actor) {
|
|
109
|
+
const op = this.app.ops.find((o) => o.name === name);
|
|
110
|
+
if (!op)
|
|
111
|
+
throw new Error(`unknown op: ${name}`);
|
|
112
|
+
const parsed = this.schemas.get(name).safeParse(args ?? {});
|
|
113
|
+
if (!parsed.success)
|
|
114
|
+
return {
|
|
115
|
+
...this.snapshot(actor), rejected: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "),
|
|
116
|
+
};
|
|
117
|
+
const { baseRevision, ...rest } = parsed.data;
|
|
118
|
+
if (baseRevision !== this.revision)
|
|
119
|
+
return {
|
|
120
|
+
...this.snapshot(actor), conflict: true, ...this.diff(baseRevision),
|
|
121
|
+
};
|
|
122
|
+
this.touch(actor);
|
|
123
|
+
const draft = structuredClone(this.state);
|
|
124
|
+
let result;
|
|
125
|
+
try {
|
|
126
|
+
result = op.handler({ doc: draft, actor, reject: (reason) => { throw new Rejection(reason); } }, rest);
|
|
127
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") &&
|
|
128
|
+
typeof result.then === "function") {
|
|
129
|
+
void Promise.resolve(result).catch(() => { });
|
|
130
|
+
throw new Error(`op ${name}: handler は同期処理に限定する。Promise は返せない。`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err instanceof Rejection)
|
|
135
|
+
return { ...this.snapshot(actor), rejected: err.message };
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
assertJson(draft);
|
|
139
|
+
if (result !== undefined)
|
|
140
|
+
assertJson(result, "/result");
|
|
141
|
+
const touched = changes(this.state, draft);
|
|
142
|
+
if (touched.length) {
|
|
143
|
+
const seq = this.seq + 1;
|
|
144
|
+
if (!Number.isSafeInteger(seq))
|
|
145
|
+
throw new Error("revision の連番が上限に達した。");
|
|
146
|
+
// ハンドラが参照を保持しても、後から確定状態を変更できない。
|
|
147
|
+
const committed = structuredClone(draft);
|
|
148
|
+
this.persist(seq, committed);
|
|
149
|
+
this.state = committed;
|
|
150
|
+
this.seq = seq;
|
|
151
|
+
const event = { seq, revision: this.revision, op: name, actor, args: rest, touched };
|
|
152
|
+
this.log.push(event);
|
|
153
|
+
if (this.log.length > LOG_LIMIT) {
|
|
154
|
+
this.log.shift();
|
|
155
|
+
this.oldest = this.log[0].seq - 1;
|
|
156
|
+
}
|
|
157
|
+
for (const wake of [...this.waiters])
|
|
158
|
+
wake();
|
|
159
|
+
this.appendLog(event);
|
|
160
|
+
}
|
|
161
|
+
return { ...this.snapshot(actor), ok: true, ...(result === undefined ? {} : { result: structuredClone(result) }) };
|
|
162
|
+
}
|
|
163
|
+
position(since) {
|
|
164
|
+
const parsed = parseRevision(since);
|
|
165
|
+
return parsed?.epoch === this.epoch && parsed.seq >= this.oldest && parsed.seq <= this.seq ? parsed.seq : null;
|
|
166
|
+
}
|
|
167
|
+
collect(since, until) {
|
|
168
|
+
return this.log.filter((e) => e.seq > since && (!until?.length || until.includes(e.op)));
|
|
169
|
+
}
|
|
170
|
+
diff(since, until) {
|
|
171
|
+
const pos = this.position(since);
|
|
172
|
+
if (pos === null)
|
|
173
|
+
return { changes: [], truncated: true };
|
|
174
|
+
const out = [];
|
|
175
|
+
for (const e of this.collect(pos, until)) {
|
|
176
|
+
const last = out.at(-1);
|
|
177
|
+
if (last?.op === e.op && last.actor === e.actor) {
|
|
178
|
+
last.revision = e.revision;
|
|
179
|
+
last.count++;
|
|
180
|
+
last.touched = [...new Set([...last.touched, ...e.touched])];
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
out.push({ revision: e.revision, op: e.op, actor: e.actor, count: 1, touched: [...e.touched] });
|
|
184
|
+
if (out.length > CHANGE_LIMIT || Buffer.byteLength(JSON.stringify(out)) > CHANGE_BYTES) {
|
|
185
|
+
return { changes: [], truncated: true };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { changes: out, truncated: false };
|
|
189
|
+
}
|
|
190
|
+
wait(since, until, timeoutMs, actor, signal) {
|
|
191
|
+
const result = (timedOut) => ({
|
|
192
|
+
...this.snapshot(actor), ...(since === undefined ? { changes: [], truncated: false } : this.diff(since, until)), timedOut,
|
|
193
|
+
});
|
|
194
|
+
const ready = () => {
|
|
195
|
+
if (since === undefined)
|
|
196
|
+
return true;
|
|
197
|
+
const pos = this.position(since);
|
|
198
|
+
return pos === null || this.collect(pos, until).length > 0;
|
|
199
|
+
};
|
|
200
|
+
if (ready() || signal?.aborted)
|
|
201
|
+
return Promise.resolve(result(!!signal?.aborted));
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
let done = false;
|
|
204
|
+
const finish = (timedOut) => {
|
|
205
|
+
if (done)
|
|
206
|
+
return;
|
|
207
|
+
done = true;
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
this.waiters.delete(wake);
|
|
210
|
+
signal?.removeEventListener("abort", abort);
|
|
211
|
+
resolve(result(timedOut));
|
|
212
|
+
};
|
|
213
|
+
const wake = () => { if (ready())
|
|
214
|
+
finish(false); };
|
|
215
|
+
const abort = () => finish(true);
|
|
216
|
+
const timer = setTimeout(() => finish(true), timeoutMs);
|
|
217
|
+
this.waiters.add(wake);
|
|
218
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
package/lib/edit.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Observed } from "./client-store.js";
|
|
2
|
+
import type { RunResult } from "./protocol.js";
|
|
3
|
+
export type EditState<Value> = {
|
|
4
|
+
active: boolean;
|
|
5
|
+
value: Value | undefined;
|
|
6
|
+
pending: boolean;
|
|
7
|
+
result: RunResult<unknown> | null;
|
|
8
|
+
error: string | null;
|
|
9
|
+
};
|
|
10
|
+
/** 下書きと観測時の呼び口を一緒に保持する。行の外に置けば移動しても残る。 */
|
|
11
|
+
export declare class EditSession<Value> {
|
|
12
|
+
private base;
|
|
13
|
+
private inflight;
|
|
14
|
+
private listeners;
|
|
15
|
+
private state;
|
|
16
|
+
getSnapshot: () => EditState<Value>;
|
|
17
|
+
subscribe: (listener: () => void) => (() => void);
|
|
18
|
+
private update;
|
|
19
|
+
private editable;
|
|
20
|
+
begin: (base: Observed<unknown>, value: Value) => void;
|
|
21
|
+
restart: (base: Observed<unknown>, value: Value) => void;
|
|
22
|
+
setValue: (value: Value) => void;
|
|
23
|
+
cancel: () => void;
|
|
24
|
+
run: (name: string, args?: Record<string, unknown>) => Promise<RunResult<unknown>>;
|
|
25
|
+
}
|