@kanf/wema-kake 0.0.0-stage → 0.1.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/LICENSE +15 -0
- package/README.md +123 -2
- package/bin/wema-kake.mjs +220 -0
- package/dist/lib/index.d.ts +13 -0
- package/dist/lib/index.js +14 -0
- package/dist/lib/shared/api.d.ts +97 -0
- package/dist/lib/shared/api.js +23 -0
- package/dist/lib/shared/delta.d.ts +24 -0
- package/dist/lib/shared/delta.js +19 -0
- package/dist/lib/shared/guide.d.ts +32 -0
- package/dist/lib/shared/guide.js +259 -0
- package/dist/lib/shared/hierarchy.d.ts +31 -0
- package/dist/lib/shared/hierarchy.js +40 -0
- package/dist/lib/shared/i18n.d.ts +23 -0
- package/dist/lib/shared/i18n.js +58 -0
- package/dist/lib/shared/note-html.d.ts +10 -0
- package/dist/lib/shared/note-html.js +28 -0
- package/dist/lib/shared/note-info.d.ts +20 -0
- package/dist/lib/shared/note-info.js +29 -0
- package/dist/lib/shared/note-text.d.ts +11 -0
- package/dist/lib/shared/note-text.js +118 -0
- package/dist/lib/shared/op-summary.d.ts +30 -0
- package/dist/lib/shared/op-summary.js +37 -0
- package/dist/lib/shared/protocol.d.ts +74 -0
- package/dist/lib/shared/protocol.js +12 -0
- package/dist/lib/shared/site.d.ts +16 -0
- package/dist/lib/shared/site.js +28 -0
- package/dist/lib/shared/slug.d.ts +4 -0
- package/dist/lib/shared/slug.js +6 -0
- package/dist/lib/shared/tools.d.ts +67 -0
- package/dist/lib/shared/tools.js +353 -0
- package/dist/lib/worker/access.d.ts +34 -0
- package/dist/lib/worker/access.js +114 -0
- package/dist/lib/worker/apply-ops.d.ts +75 -0
- package/dist/lib/worker/apply-ops.js +335 -0
- package/dist/lib/worker/assets.d.ts +4 -0
- package/dist/lib/worker/assets.js +35 -0
- package/dist/lib/worker/config.d.ts +52 -0
- package/dist/lib/worker/config.js +23 -0
- package/dist/lib/worker/first-page.d.ts +10 -0
- package/dist/lib/worker/first-page.js +30 -0
- package/dist/lib/worker/images.d.ts +3 -0
- package/dist/lib/worker/images.js +54 -0
- package/dist/lib/worker/index.d.ts +8 -0
- package/dist/lib/worker/index.js +72 -0
- package/dist/lib/worker/indexer.d.ts +42 -0
- package/dist/lib/worker/indexer.js +93 -0
- package/dist/lib/worker/mcp/board-access.d.ts +28 -0
- package/dist/lib/worker/mcp/board-access.js +67 -0
- package/dist/lib/worker/mcp/server.d.ts +9 -0
- package/dist/lib/worker/mcp/server.js +39 -0
- package/dist/lib/worker/page-do.d.ts +330 -0
- package/dist/lib/worker/page-do.js +1168 -0
- package/dist/lib/worker/page-queries.d.ts +28 -0
- package/dist/lib/worker/page-queries.js +88 -0
- package/dist/lib/worker/pages-api.d.ts +5 -0
- package/dist/lib/worker/pages-api.js +257 -0
- package/dist/lib/worker/plain-text.d.ts +22 -0
- package/dist/lib/worker/plain-text.js +113 -0
- package/dist/lib/worker/revert.d.ts +21 -0
- package/dist/lib/worker/revert.js +126 -0
- package/dist/lib/worker/sanitize.d.ts +4 -0
- package/dist/lib/worker/sanitize.js +135 -0
- package/dist/lib/worker/validate.d.ts +21 -0
- package/dist/lib/worker/validate.js +218 -0
- package/dist/web/assets/index-CVwMafiH.js +5 -0
- package/dist/web/assets/index-Cgi5orcJ.css +1 -0
- package/dist/web/favicon.svg +13 -0
- package/dist/web/index.html +14 -0
- package/migrations/0001_init.sql +18 -0
- package/migrations/0002_fts.sql +9 -0
- package/migrations/0003_hierarchy.sql +9 -0
- package/migrations/0004_settings.sql +12 -0
- package/migrations/0005_bookmarks.sql +8 -0
- package/migrations/0006_page_color.sql +5 -0
- package/package.json +75 -4
- package/template/.dev.vars.example +4 -0
- package/template/README.md +55 -0
- package/template/gitignore +8 -0
- package/template/package.json +25 -0
- package/template/src/config.ts +18 -0
- package/template/src/index.ts +9 -0
- package/template/tsconfig.json +16 -0
- package/template/wrangler.jsonc +57 -0
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { INDEX_DELAY_MS, MAX_TITLE_LENGTH, PAGE_COLORS, UNSET_PAGE_COLOR, } from '../shared/api';
|
|
3
|
+
import { invertDeltas, } from '../shared/delta';
|
|
4
|
+
import { systemSummary } from '../shared/op-summary';
|
|
5
|
+
import { CLOSE_AUTH_EXPIRED, CLOSE_PAGE_DELETED, PING, PONG, } from '../shared/protocol';
|
|
6
|
+
import { childPageOf, childRejection, MAX_DEPTH, placedPages, touchesChildren, } from '../shared/hierarchy';
|
|
7
|
+
import { applyDeltas, childNoteColors, childPages, readBoard, readBoardWithAuthors, readBoardWithInfos, readNotes, sanitizeDeltas, UNSET_FLAGS, } from './apply-ops';
|
|
8
|
+
import { buildLayout, buildPageContent, contentHash, indexedChildren, removePage, touchPage, writePage, } from './indexer';
|
|
9
|
+
import { applyRevert } from './revert';
|
|
10
|
+
import { SanitizeError } from './sanitize';
|
|
11
|
+
import { clampLimit, exceedsBytes, MAX_OP_BYTES, parseDeltas, parseId, RejectError, } from './validate';
|
|
12
|
+
const MAX_SUMMARY_LENGTH = 500;
|
|
13
|
+
const DEFAULT_LIST_OPS = 50;
|
|
14
|
+
const MAX_LIST_OPS = 200;
|
|
15
|
+
/** ops を残す期間と件数。どちらかに収まっていれば残す(再接続時の差分、履歴、取り消しに使う) */
|
|
16
|
+
const OPS_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const OPS_RETENTION_COUNT = 1000;
|
|
18
|
+
/** 受信メッセージの上限(文字数)。1 回の ops に入る text の合計の上限より少し大きくしている */
|
|
19
|
+
const MAX_MESSAGE_LENGTH = 2_000_000;
|
|
20
|
+
/** 再接続時に差分で返す ops の数の上限。これより離れていたらスナップショットを返す */
|
|
21
|
+
const MAX_REPLAY_OPS = 500;
|
|
22
|
+
/** 同じく、差分の合計の大きさ(文字数)の上限。大きな text の更新が続いた場合に効く */
|
|
23
|
+
const MAX_REPLAY_LENGTH = 4_000_000;
|
|
24
|
+
/** Worker が WebSocket の接続要求を DO へ転送するときに、認証済みの主体を入れるヘッダー */
|
|
25
|
+
export const ACTOR_HEADER = 'X-Wema-Actor';
|
|
26
|
+
/** 同じく、認証の期限(UNIX 秒)を入れるヘッダー。期限がなければ付けない */
|
|
27
|
+
export const AUTH_EXPIRES_HEADER = 'X-Wema-Auth-Expires';
|
|
28
|
+
function isExpired(attachment) {
|
|
29
|
+
return attachment.expiresAt !== undefined && Date.now() >= attachment.expiresAt * 1000;
|
|
30
|
+
}
|
|
31
|
+
/** ページがないときの `reason`。Worker は、これを 404 にする */
|
|
32
|
+
export const REASON_PAGE_NOT_FOUND = 'page not found';
|
|
33
|
+
/** 受け取った付箋と、今ある付箋の間に空ける幅 */
|
|
34
|
+
const RECEIVE_GAP = 40;
|
|
35
|
+
/**
|
|
36
|
+
* 受け取る付箋を、互いの位置関係を保ったまま、今ある付箋の下へずらす(左端は、今ある付箋にそろえる)。
|
|
37
|
+
* `existing` は、今ある付箋の左端と下端。付箋がなければ、どちらも null
|
|
38
|
+
*/
|
|
39
|
+
function placeBelow(incoming, existing) {
|
|
40
|
+
const dx = (existing.left ?? 0) - Math.min(...incoming.map((n) => n.x));
|
|
41
|
+
const top = existing.bottom === null ? 0 : existing.bottom + RECEIVE_GAP;
|
|
42
|
+
const dy = top - Math.min(...incoming.map((n) => n.y));
|
|
43
|
+
for (const note of incoming) {
|
|
44
|
+
note.x += dx;
|
|
45
|
+
note.y += dy;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const META_TABLE = `
|
|
49
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
50
|
+
key TEXT PRIMARY KEY,
|
|
51
|
+
value TEXT NOT NULL
|
|
52
|
+
)`;
|
|
53
|
+
/** 添字 + 1 がスキーマのバージョン。変更は末尾に足し、既存の要素は書き換えない */
|
|
54
|
+
const MIGRATIONS = [
|
|
55
|
+
`
|
|
56
|
+
CREATE TABLE notes (
|
|
57
|
+
id TEXT PRIMARY KEY,
|
|
58
|
+
x REAL NOT NULL, y REAL NOT NULL,
|
|
59
|
+
width REAL NOT NULL, height REAL NOT NULL,
|
|
60
|
+
text TEXT NOT NULL,
|
|
61
|
+
color TEXT NOT NULL,
|
|
62
|
+
z_index INTEGER NOT NULL,
|
|
63
|
+
auto_size INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
extra TEXT,
|
|
65
|
+
created_by TEXT,
|
|
66
|
+
updated_at INTEGER NOT NULL,
|
|
67
|
+
updated_by TEXT
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE edges (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
from_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
73
|
+
to_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
|
74
|
+
props TEXT NOT NULL,
|
|
75
|
+
created_by TEXT,
|
|
76
|
+
updated_at INTEGER NOT NULL
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
CREATE TABLE ops (
|
|
80
|
+
seq INTEGER PRIMARY KEY,
|
|
81
|
+
actor TEXT NOT NULL,
|
|
82
|
+
client_id TEXT NOT NULL,
|
|
83
|
+
op_id TEXT NOT NULL,
|
|
84
|
+
summary TEXT,
|
|
85
|
+
reverts INTEGER,
|
|
86
|
+
reverted_by INTEGER,
|
|
87
|
+
created_at INTEGER NOT NULL,
|
|
88
|
+
-- 大きくなる列は最後に置く。一覧や取り消しの判定で手前の列だけを読むときに、
|
|
89
|
+
-- これらの中身(最大 1.8MB)まで読まずに済む
|
|
90
|
+
body TEXT NOT NULL,
|
|
91
|
+
fixups TEXT
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
CREATE INDEX ops_reverted_by ON ops (reverted_by) WHERE reverted_by IS NOT NULL;
|
|
95
|
+
|
|
96
|
+
CREATE INDEX edges_from ON edges (from_id);
|
|
97
|
+
CREATE INDEX edges_to ON edges (to_id);
|
|
98
|
+
CREATE UNIQUE INDEX ops_client_op ON ops (actor, client_id, op_id);
|
|
99
|
+
|
|
100
|
+
INSERT INTO meta (key, value) VALUES ('seq', '0');
|
|
101
|
+
`,
|
|
102
|
+
// 長い本文を畳んで表示する付箋(wema 0.9.0 の WemaNote.foldable)
|
|
103
|
+
`ALTER TABLE notes ADD COLUMN foldable INTEGER NOT NULL DEFAULT 0;`,
|
|
104
|
+
];
|
|
105
|
+
/** ページ 1 つ分の付箋・接続線・操作履歴を持つ */
|
|
106
|
+
export class PageDO extends DurableObject {
|
|
107
|
+
/** 適用済みのスキーマのバージョン。0 は一度も書き込まれていないページ */
|
|
108
|
+
version;
|
|
109
|
+
/** ページを削除するたびに増える。削除の前に受け付けた書き込みを、削除の後に実行しないために使う */
|
|
110
|
+
generation = 0;
|
|
111
|
+
/** 実行中、または順番を待っている削除の数 */
|
|
112
|
+
deleting = 0;
|
|
113
|
+
/** 今まさに子にしようとしているページ(adoptChildren の実行中)。互いを同時に子にするのを断る */
|
|
114
|
+
adopting = new Set();
|
|
115
|
+
/** 処理中の applyOps。次の呼び出しはこれが終わってから始める */
|
|
116
|
+
applyQueue = Promise.resolve();
|
|
117
|
+
constructor(ctx, env) {
|
|
118
|
+
super(ctx, env);
|
|
119
|
+
// 接続維持の ping には DO を起こさずに応答する
|
|
120
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(PING, PONG));
|
|
121
|
+
this.version = this.readVersion();
|
|
122
|
+
// 書き込みのないページには何も保存しない(存在しないスラッグを開いただけで
|
|
123
|
+
// ストレージが残らないようにする)。スキーマは最初の書き込みで作る
|
|
124
|
+
if (this.version > 0)
|
|
125
|
+
this.migrate();
|
|
126
|
+
}
|
|
127
|
+
get sql() {
|
|
128
|
+
return this.ctx.storage.sql;
|
|
129
|
+
}
|
|
130
|
+
/** スキーマがある(version > 0)ときだけ呼ぶ */
|
|
131
|
+
getMeta(key) {
|
|
132
|
+
const row = this.sql.exec(`SELECT value FROM meta WHERE key = ?`, key).toArray()[0];
|
|
133
|
+
return row ? row.value : null;
|
|
134
|
+
}
|
|
135
|
+
setMeta(key, value) {
|
|
136
|
+
if (value === null)
|
|
137
|
+
this.sql.exec(`DELETE FROM meta WHERE key = ?`, key);
|
|
138
|
+
else
|
|
139
|
+
this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)`, key, value);
|
|
140
|
+
}
|
|
141
|
+
readVersion() {
|
|
142
|
+
const hasMeta = this.sql
|
|
143
|
+
.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta'`)
|
|
144
|
+
.toArray().length;
|
|
145
|
+
return hasMeta ? Number(this.getMeta('version') ?? 0) : 0;
|
|
146
|
+
}
|
|
147
|
+
/** スキーマを最新にする。書き込みの前に呼ぶ */
|
|
148
|
+
migrate() {
|
|
149
|
+
if (this.version >= MIGRATIONS.length)
|
|
150
|
+
return;
|
|
151
|
+
this.ctx.storage.transactionSync(() => {
|
|
152
|
+
this.sql.exec(META_TABLE);
|
|
153
|
+
// 自分のスラッグは、スキーマを作るとき(最初の書き込み)に保存する。D1 への反映(alarm)は
|
|
154
|
+
// リクエストを伴わずに起動され、そこでは名前を取得できない可能性があるため
|
|
155
|
+
if (this.version === 0) {
|
|
156
|
+
if (this.ctx.id.name)
|
|
157
|
+
this.setMeta('slug', this.ctx.id.name);
|
|
158
|
+
// ページを作るたびに変わる値。削除して作り直したページを、前のページと見分けるのに使う
|
|
159
|
+
this.setMeta('epoch', crypto.randomUUID());
|
|
160
|
+
}
|
|
161
|
+
for (const migration of MIGRATIONS.slice(this.version)) {
|
|
162
|
+
this.sql.exec(migration);
|
|
163
|
+
}
|
|
164
|
+
this.setMeta('version', String(MIGRATIONS.length));
|
|
165
|
+
});
|
|
166
|
+
const created = this.version === 0;
|
|
167
|
+
this.version = MIGRATIONS.length;
|
|
168
|
+
// ページができたことを、開いているブラウザに知らせる(epoch を渡す)
|
|
169
|
+
if (created)
|
|
170
|
+
this.broadcast(this.metaJson());
|
|
171
|
+
}
|
|
172
|
+
/** 表示名、ページの色、epoch を伝えるメッセージ。スキーマがある(version > 0)ときだけ呼ぶ */
|
|
173
|
+
metaJson() {
|
|
174
|
+
return JSON.stringify({
|
|
175
|
+
type: 'meta',
|
|
176
|
+
title: this.getMeta('title'),
|
|
177
|
+
color: this.getMeta('color'),
|
|
178
|
+
epoch: this.getMeta('epoch'),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** スキーマがある(version > 0)ときだけ呼ぶ */
|
|
182
|
+
getSeq() {
|
|
183
|
+
return Number(this.getMeta('seq'));
|
|
184
|
+
}
|
|
185
|
+
getSnapshot() {
|
|
186
|
+
if (this.version === 0) {
|
|
187
|
+
return { seq: 0, title: null, color: null, epoch: null, data: { version: 1, notes: [], edges: [] }, info: {} };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
seq: this.getSeq(),
|
|
191
|
+
title: this.getMeta('title'),
|
|
192
|
+
color: this.getMeta('color'),
|
|
193
|
+
epoch: this.getMeta('epoch'),
|
|
194
|
+
...readBoardWithInfos(this.sql),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** agent(MCP)に渡すボード。付箋には作成者が付く。ページがまだ作られていなければ null */
|
|
198
|
+
getBoardState() {
|
|
199
|
+
if (this.version === 0)
|
|
200
|
+
return null;
|
|
201
|
+
return { title: this.getMeta('title'), ...readBoardWithAuthors(this.sql) };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 表示名を変える。空文字なら未設定に戻す(表示はスラッグになる)。
|
|
205
|
+
* 付箋のデルタではないので ops には記録しない。
|
|
206
|
+
*
|
|
207
|
+
* まだ作られていないページに呼ぶと、ページができる。`mustExist` なら、作らずに断る
|
|
208
|
+
* (一覧の画面からの変更。古い一覧に残っている削除済みのページを、作り直さないため)
|
|
209
|
+
*/
|
|
210
|
+
setTitle(input, options = {}) {
|
|
211
|
+
const title = parseTitle(input);
|
|
212
|
+
if (title === undefined)
|
|
213
|
+
return { ok: false, reason: 'invalid title' };
|
|
214
|
+
if (options.mustExist && this.version === 0)
|
|
215
|
+
return { ok: false, reason: REASON_PAGE_NOT_FOUND };
|
|
216
|
+
this.migrate();
|
|
217
|
+
this.setMeta('title', title);
|
|
218
|
+
this.broadcast(this.metaJson());
|
|
219
|
+
this.scheduleIndexing();
|
|
220
|
+
return { ok: true, title };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* ページの色を変える。null なら、付けていない状態に戻す。表示名と同じく、ops には記録しない。
|
|
224
|
+
* まだ作られていないページと、削除の途中のページには、付けない(色を選んだだけで、ページを
|
|
225
|
+
* 作らない。削除されたページを、古いタブから作り直さない)
|
|
226
|
+
*/
|
|
227
|
+
async setColor(input, actor = 'system') {
|
|
228
|
+
const set = this.setOwnColor(input);
|
|
229
|
+
if (!set.ok)
|
|
230
|
+
return set;
|
|
231
|
+
// 親ページにある自分の付箋を、同じ色にする。色が変わっていなくても合わせる(前に合わせ損ねて
|
|
232
|
+
// いたら、選び直すと直る)。色を外したときは、付箋を既定の色へ戻す
|
|
233
|
+
await this.recolorOwnNote(actor, true);
|
|
234
|
+
return set;
|
|
235
|
+
}
|
|
236
|
+
/** 親ページにある自分の付箋を、このページの色にする。失敗しても、例外にしない */
|
|
237
|
+
async recolorOwnNote(actor, resetUnset) {
|
|
238
|
+
const parent = this.getMeta('parent');
|
|
239
|
+
if (parent === null)
|
|
240
|
+
return;
|
|
241
|
+
try {
|
|
242
|
+
await this.env.PAGE.getByName(parent).recolorChildNotes(this.slug(), actor, resetUnset);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
console.error('failed to recolor the child note', parent, e);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* 色を変える。親ページにある付箋の色は、変えない(`setColor` との違い)。親ページの DO が、
|
|
250
|
+
* 子ページの付箋の色を変えた後に、子ページへ呼ぶ(matchChildColors)。書き込みの順番待ちに入れない
|
|
251
|
+
*/
|
|
252
|
+
setOwnColor(input) {
|
|
253
|
+
if (input !== null && (typeof input !== 'string' || !PAGE_COLORS.includes(input))) {
|
|
254
|
+
return { ok: false, reason: 'invalid color' };
|
|
255
|
+
}
|
|
256
|
+
if (this.version === 0 || this.deleting > 0)
|
|
257
|
+
return { ok: false, reason: REASON_PAGE_NOT_FOUND };
|
|
258
|
+
// 同じ色なら、何もしない(索引を更新すると、変更のないページの更新日時が変わる)
|
|
259
|
+
if (input !== this.getMeta('color')) {
|
|
260
|
+
this.setMeta('color', input);
|
|
261
|
+
this.broadcast(this.metaJson());
|
|
262
|
+
this.scheduleIndexing();
|
|
263
|
+
}
|
|
264
|
+
return { ok: true, color: input };
|
|
265
|
+
}
|
|
266
|
+
// --- 階層(src/shared/hierarchy.ts) ---
|
|
267
|
+
//
|
|
268
|
+
// 子ページの付箋は親ページの DO にあり、「親は誰か」は子ページの DO にある(meta の parent)。
|
|
269
|
+
// 正とするのは、子ページの側。親ページの DO は、子ページの付箋を作るデルタを適用する前に、
|
|
270
|
+
// 子ページの DO へ setParent を呼ぶ。断られたら、デルタを拒否する。
|
|
271
|
+
//
|
|
272
|
+
// setParent / clearParent / getPageRef は、書き込みの順番待ちに入れない。親ページの DO が
|
|
273
|
+
// 順番待ちの中から呼ぶので、入れると、互いを待って止まることがある。
|
|
274
|
+
/**
|
|
275
|
+
* 親をたどるための情報と、付箋の数。ページがなければ null。
|
|
276
|
+
* 索引(D1)への反映を待たずに読めるので、作ったばかりのページの概要にも使う
|
|
277
|
+
*/
|
|
278
|
+
getPageRef() {
|
|
279
|
+
if (this.version === 0)
|
|
280
|
+
return null;
|
|
281
|
+
const noteCount = this.sql.exec(`SELECT count(*) AS n FROM notes`).one().n;
|
|
282
|
+
return {
|
|
283
|
+
title: this.getMeta('title'),
|
|
284
|
+
color: this.getMeta('color'),
|
|
285
|
+
parent: this.getMeta('parent'),
|
|
286
|
+
noteCount,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* ページの概要(子ページの付箋の表示に使うもの)。ページがなければ null。
|
|
291
|
+
* 索引(D1)への反映を待たないので、変更の直後でも、今の付箋の数と配置が入る
|
|
292
|
+
*/
|
|
293
|
+
getSummary() {
|
|
294
|
+
const ref = this.getPageRef();
|
|
295
|
+
if (!ref)
|
|
296
|
+
return null;
|
|
297
|
+
return {
|
|
298
|
+
title: ref.title,
|
|
299
|
+
color: ref.color,
|
|
300
|
+
note_count: ref.noteCount,
|
|
301
|
+
parent: ref.parent,
|
|
302
|
+
layout: buildLayout(readNotes(this.sql)),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/** このページを `parent` の子にする。すでに別の親があるか、輪になるなら、理由を返す。置けたら null */
|
|
306
|
+
setParent(parent) {
|
|
307
|
+
if (this.version === 0 || this.deleting > 0)
|
|
308
|
+
return 'not-found';
|
|
309
|
+
const current = this.getMeta('parent');
|
|
310
|
+
if (current !== null && current !== parent)
|
|
311
|
+
return 'other-parent';
|
|
312
|
+
// 相手を自分の子にしている(または、今まさに子にしようとしている)なら、輪になる
|
|
313
|
+
if (this.adopting.has(parent) || childPages(this.sql).has(parent))
|
|
314
|
+
return 'ancestor';
|
|
315
|
+
if (current === null) {
|
|
316
|
+
this.setMeta('parent', parent);
|
|
317
|
+
this.scheduleIndexing();
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
/** `parent` の子であれば、ルートへ戻す */
|
|
322
|
+
clearParent(parent) {
|
|
323
|
+
if (this.version === 0 || this.getMeta('parent') !== parent)
|
|
324
|
+
return;
|
|
325
|
+
this.setMeta('parent', null);
|
|
326
|
+
this.scheduleIndexing();
|
|
327
|
+
}
|
|
328
|
+
/** このページのスラッグ */
|
|
329
|
+
slug() {
|
|
330
|
+
const slug = (this.version > 0 ? this.getMeta('slug') : null) ?? this.ctx.id.name;
|
|
331
|
+
if (!slug)
|
|
332
|
+
throw new Error('page slug is not available in the Durable Object');
|
|
333
|
+
return slug;
|
|
334
|
+
}
|
|
335
|
+
/** 先祖のページ(親、その親、…の順)。上限を超えてたどらない。パンくずと、輪の検査に使う */
|
|
336
|
+
async ancestors() {
|
|
337
|
+
if (this.version === 0)
|
|
338
|
+
return [];
|
|
339
|
+
const found = [];
|
|
340
|
+
for (let name = this.getMeta('parent'); name !== null && found.length < MAX_DEPTH;) {
|
|
341
|
+
const ref = await this.env.PAGE.getByName(name).getPageRef();
|
|
342
|
+
found.push({ name, title: ref?.title ?? null });
|
|
343
|
+
name = ref?.parent ?? null;
|
|
344
|
+
}
|
|
345
|
+
return found;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `wanted`(新しく置こうとしている子ページ)を、このページの子にする。
|
|
349
|
+
* 置けないものが 1 つでもあれば、すべて取りやめて、理由を返す。置けたら null を返す
|
|
350
|
+
*/
|
|
351
|
+
async adoptChildren(wanted) {
|
|
352
|
+
const slug = this.slug();
|
|
353
|
+
if (wanted.includes(slug))
|
|
354
|
+
return childRejection('self', slug);
|
|
355
|
+
/** 先祖の中に、置こうとしているページがあれば、輪になる */
|
|
356
|
+
const loop = (ancestors) => {
|
|
357
|
+
const page = wanted.find((name) => ancestors.some((ancestor) => ancestor.name === name));
|
|
358
|
+
return page === undefined ? null : childRejection('ancestor', page);
|
|
359
|
+
};
|
|
360
|
+
let done = false;
|
|
361
|
+
wanted.forEach((page) => this.adopting.add(page));
|
|
362
|
+
try {
|
|
363
|
+
const ancestors = await this.ancestors();
|
|
364
|
+
const looped = loop(ancestors);
|
|
365
|
+
if (looped !== null)
|
|
366
|
+
return looped;
|
|
367
|
+
for (const page of wanted) {
|
|
368
|
+
// 置いた後の最も深い段が、上限を超えないこと。置かれる側に、残りの段数に収まるかを聞く
|
|
369
|
+
const fits = await this.env.PAGE.getByName(page).fitsWithin(MAX_DEPTH - ancestors.length - 1);
|
|
370
|
+
if (!fits)
|
|
371
|
+
return childRejection('too-deep', page);
|
|
372
|
+
}
|
|
373
|
+
for (const page of wanted) {
|
|
374
|
+
const code = await this.env.PAGE.getByName(page).setParent(slug);
|
|
375
|
+
if (code !== null)
|
|
376
|
+
return childRejection(code, page);
|
|
377
|
+
}
|
|
378
|
+
// 子にした後で、先祖をたどり直す。上の検査の後に、他のページで同時に置く操作があると、
|
|
379
|
+
// 3 ページ以上の輪ができることがある(相手も同じ検査をするので、少なくとも片方は取りやめる)
|
|
380
|
+
const loopedNow = loop(await this.ancestors());
|
|
381
|
+
if (loopedNow !== null)
|
|
382
|
+
return loopedNow;
|
|
383
|
+
done = true;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
wanted.forEach((page) => this.adopting.delete(page));
|
|
388
|
+
if (!done) {
|
|
389
|
+
// 取りやめたら、すべて戻す。setParent が済んだのに応答が届かなかった分も含める
|
|
390
|
+
// (clearParent は、親がこのページのときだけ消すので、済んでいない分に呼んでも害はない)
|
|
391
|
+
await this.releaseOrphans(wanted);
|
|
392
|
+
// 戻しきれなかった分は、次の索引の更新で直す
|
|
393
|
+
this.scheduleIndexing();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* このページを根とする部分木が、`levels` 段に収まるか。子ページの DO を順にたどるので、
|
|
399
|
+
* 直前の変更も反映される(D1 の索引は、変更の数秒後にしか反映されない)
|
|
400
|
+
*/
|
|
401
|
+
async fitsWithin(levels) {
|
|
402
|
+
if (levels < 1)
|
|
403
|
+
return false;
|
|
404
|
+
if (this.version === 0)
|
|
405
|
+
return true;
|
|
406
|
+
for (const child of childPages(this.sql)) {
|
|
407
|
+
if (!(await this.env.PAGE.getByName(child).fitsWithin(levels - 1)))
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* `candidates` のうち、子ページの付箋がもうないページを、ルートへ戻す。
|
|
414
|
+
* 失敗しても、書き込みは取り消さない。食い違いは、次の索引の更新(reconcileChildren)で直す
|
|
415
|
+
*/
|
|
416
|
+
async releaseOrphans(candidates) {
|
|
417
|
+
if (this.version === 0)
|
|
418
|
+
return;
|
|
419
|
+
const placed = childPages(this.sql);
|
|
420
|
+
const slug = this.slug();
|
|
421
|
+
for (const page of new Set(candidates)) {
|
|
422
|
+
if (placed.has(page))
|
|
423
|
+
continue;
|
|
424
|
+
try {
|
|
425
|
+
await this.env.PAGE.getByName(page).clearParent(slug);
|
|
426
|
+
}
|
|
427
|
+
catch (e) {
|
|
428
|
+
console.error('failed to release a child page', page, e);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* 子ページの付箋の増減を伴う書き込みを行う。`run` の前に、新しく置く子ページを子にし、
|
|
434
|
+
* 後に、付箋がなくなった子ページをルートへ戻す。置けなければ、`run` を呼ばずに `rejected` を返す
|
|
435
|
+
*/
|
|
436
|
+
async withChildren(deltas, run, rejected) {
|
|
437
|
+
// 付箋の移動、本文の編集、接続線の操作では、子ページの集合は変わらない。何も調べずに進める
|
|
438
|
+
if (!touchesChildren(deltas))
|
|
439
|
+
return run();
|
|
440
|
+
const before = childPages(this.sql);
|
|
441
|
+
const wanted = placedPages(deltas).filter((page) => !before.has(page));
|
|
442
|
+
const reason = wanted.length > 0 ? await this.adoptChildren(wanted) : null;
|
|
443
|
+
if (reason !== null)
|
|
444
|
+
return rejected(reason);
|
|
445
|
+
try {
|
|
446
|
+
return run();
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
await this.releaseOrphans([...before, ...wanted]);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 子ページの付箋の色と、子ページの色をそろえる。`applied` は、適用して記録したデルタ
|
|
454
|
+
* (断られた変更と、今と同じ色への変更は、入っていない)。
|
|
455
|
+
*
|
|
456
|
+
* - 子ページの付箋の色を変えたら、子ページの色を、その色にする
|
|
457
|
+
* - 子ページの付箋を置いたら(Undo や取り消しで戻したときも)、付箋の色を、子ページの色にする。
|
|
458
|
+
* 子ページに色が付いていなければ、付箋の色のままにする
|
|
459
|
+
*
|
|
460
|
+
* 失敗しても、書き込みは取り消さない。食い違いは、子ページの次の索引の更新か、子ページの色の
|
|
461
|
+
* 選び直しで直る(どちらも、付箋を子ページの色に合わせる)
|
|
462
|
+
*/
|
|
463
|
+
async matchChildColors(applied, actor) {
|
|
464
|
+
/** 色を変えた付箋 */
|
|
465
|
+
const recolored = new Set();
|
|
466
|
+
/** 子ページの付箋として置いた付箋 */
|
|
467
|
+
const placed = new Set();
|
|
468
|
+
for (const d of applied) {
|
|
469
|
+
if (d.type === 'note:create' && childPageOf(d.note) !== undefined)
|
|
470
|
+
placed.add(d.note.id);
|
|
471
|
+
else if (d.type === 'note:update' && childPageOf(d.after) !== undefined)
|
|
472
|
+
placed.add(d.noteId);
|
|
473
|
+
else if (d.type === 'note:update' && typeof d.after.color === 'string')
|
|
474
|
+
recolored.add(d.noteId);
|
|
475
|
+
}
|
|
476
|
+
// 付箋の移動や本文の編集では、何も読まずに終える
|
|
477
|
+
if (recolored.size === 0 && placed.size === 0)
|
|
478
|
+
return;
|
|
479
|
+
for (const note of childNoteColors(this.sql)) {
|
|
480
|
+
try {
|
|
481
|
+
if (placed.has(note.id)) {
|
|
482
|
+
await this.recolorChildNotesInOrder(note.page, actor, false);
|
|
483
|
+
}
|
|
484
|
+
else if (recolored.has(note.id)) {
|
|
485
|
+
const set = await this.env.PAGE.getByName(note.page).setOwnColor(note.color);
|
|
486
|
+
// ページに付けられない色(PAGE_COLORS にない色。MCP からは、どの色も付箋に付けられる)
|
|
487
|
+
if (!set.ok)
|
|
488
|
+
console.warn('could not match the color of a child page', note.page, set.reason);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
catch (e) {
|
|
492
|
+
console.error('failed to match the color of a child page', note.page, e);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 子ページ `page` の付箋の色を、子ページの今の色にする。付箋の更新として記録するので、
|
|
498
|
+
* 開いているブラウザにも届く。色は、順番待ちに入ってから、子ページへ聞く(呼び出しが前後しても、
|
|
499
|
+
* 最後に子ページの今の色になる)
|
|
500
|
+
*
|
|
501
|
+
* @param resetUnset 子ページに色が付いていないときに、付箋を既定の色へ戻すか
|
|
502
|
+
*/
|
|
503
|
+
recolorChildNotes(page, actor, resetUnset) {
|
|
504
|
+
return this.write(() => this.recolorChildNotesInOrder(page, actor, resetUnset), undefined);
|
|
505
|
+
}
|
|
506
|
+
async recolorChildNotesInOrder(page, actor, resetUnset) {
|
|
507
|
+
if (this.version === 0)
|
|
508
|
+
return;
|
|
509
|
+
const ref = await this.env.PAGE.getByName(page).getPageRef();
|
|
510
|
+
const color = ref && (ref.color ?? (resetUnset ? UNSET_PAGE_COLOR : null));
|
|
511
|
+
if (!color)
|
|
512
|
+
return;
|
|
513
|
+
const notes = childNoteColors(this.sql).filter((note) => note.page === page && note.color !== color);
|
|
514
|
+
if (notes.length === 0)
|
|
515
|
+
return;
|
|
516
|
+
await this.applyOpsInOrder({
|
|
517
|
+
actor,
|
|
518
|
+
clientId: 'system',
|
|
519
|
+
opId: crypto.randomUUID(),
|
|
520
|
+
deltas: notes.map((note) => ({ type: 'note:update', noteId: note.id, before: { color: note.color }, after: { color } })),
|
|
521
|
+
summary: systemSummary.childRecolored(page),
|
|
522
|
+
}, undefined, false);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* ページを削除する前に、階層から外す。親ページにある自分の付箋を消し、子ページはルートへ戻す
|
|
526
|
+
* (子孫ごとの削除はしない)。失敗しても削除は続ける。残った食い違いは、付箋の側が
|
|
527
|
+
* 「削除済み」と表示され、親ページの索引の更新(reconcileChildren)でも直る
|
|
528
|
+
*/
|
|
529
|
+
async detachFromHierarchy(slug, actor) {
|
|
530
|
+
const parent = this.getMeta('parent');
|
|
531
|
+
// 1 つの失敗で、残りを飛ばさない。子ページの側は、親がなくなっていれば、次の索引の更新で
|
|
532
|
+
// 自分でルートへ戻る(healParent)
|
|
533
|
+
const attempt = async (what, run) => {
|
|
534
|
+
try {
|
|
535
|
+
await run();
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
console.error(`failed to detach a deleted page from the hierarchy (${what})`, slug, e);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
if (parent !== null) {
|
|
542
|
+
await attempt(`parent ${parent}`, () => this.env.PAGE.getByName(parent).removeChildNotes(slug, actor));
|
|
543
|
+
}
|
|
544
|
+
for (const child of childPages(this.sql)) {
|
|
545
|
+
await attempt(`child ${child}`, async () => {
|
|
546
|
+
const stub = this.env.PAGE.getByName(child);
|
|
547
|
+
await stub.clearParent(slug);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* 子ページ `page` の付箋を、このページから消す(子ページが削除されたとき)。
|
|
553
|
+
* 付箋の削除として記録するので、開いているブラウザにも届く
|
|
554
|
+
*/
|
|
555
|
+
removeChildNotes(page, actor) {
|
|
556
|
+
// 付箋を読むのは、順番待ちに入ってから。置いている途中の付箋(setParent は済んだが、
|
|
557
|
+
// まだ書き込んでいない)を取りこぼさないため
|
|
558
|
+
return this.write(async () => {
|
|
559
|
+
if (this.version === 0)
|
|
560
|
+
return;
|
|
561
|
+
const notes = readBoard(this.sql).notes.filter((note) => childPageOf(note) === page);
|
|
562
|
+
if (notes.length === 0)
|
|
563
|
+
return;
|
|
564
|
+
await this.applyOpsInOrder({
|
|
565
|
+
actor,
|
|
566
|
+
clientId: 'system',
|
|
567
|
+
opId: crypto.randomUUID(),
|
|
568
|
+
deltas: notes.map((note) => ({ type: 'note:delete', note })),
|
|
569
|
+
summary: systemSummary.childRemoved(page),
|
|
570
|
+
});
|
|
571
|
+
}, undefined);
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* ページを新しく作る。すでにあれば何もしない(既存のページの表示名を書き換えない)。
|
|
575
|
+
* 付箋を書き込めばページはできるので、これは表示名だけのページを先に作るためのもの。
|
|
576
|
+
*/
|
|
577
|
+
createPage(input) {
|
|
578
|
+
const title = parseTitle(input ?? '');
|
|
579
|
+
if (title === undefined)
|
|
580
|
+
return { ok: false, reason: 'invalid title' };
|
|
581
|
+
if (this.version > 0)
|
|
582
|
+
return { ok: false, reason: 'exists' };
|
|
583
|
+
this.migrate();
|
|
584
|
+
this.setMeta('title', title);
|
|
585
|
+
this.scheduleIndexing();
|
|
586
|
+
return { ok: true };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* ページを、付箋と接続線を置いた状態で作り、D1 の索引にも反映する(最初のページ用。作った直後の
|
|
590
|
+
* 一覧に出すため、索引への反映を待つ)。すでにあれば何もせず、false を返す
|
|
591
|
+
*/
|
|
592
|
+
async createWithContent(title, deltas) {
|
|
593
|
+
if (this.version > 0)
|
|
594
|
+
return false;
|
|
595
|
+
// 付箋を先に書く。断られたときに、表示名だけの空のページを残さない(残ると、作り直せない)
|
|
596
|
+
const applied = await this.applyOps({
|
|
597
|
+
actor: 'system',
|
|
598
|
+
clientId: 'system',
|
|
599
|
+
opId: crypto.randomUUID(),
|
|
600
|
+
deltas,
|
|
601
|
+
summary: systemSummary.pageCreated(),
|
|
602
|
+
});
|
|
603
|
+
if (!applied.ok)
|
|
604
|
+
throw new Error(`could not create the page content: ${applied.reason}`);
|
|
605
|
+
this.setTitle(title);
|
|
606
|
+
await this.reindexInOrder(Date.now());
|
|
607
|
+
return true;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* ページを削除する。付箋、接続線、履歴、表示名と、D1 の索引を消す。取り消しはできない。
|
|
611
|
+
* 貼った画像は R2 に残る(どのページの画像かを記録していない)。
|
|
612
|
+
*/
|
|
613
|
+
deletePage(actor = 'system') {
|
|
614
|
+
// 順番を待っている書き込みと、削除の途中で届く書き込みは、削除の後に実行させない
|
|
615
|
+
// (実行すると、消したページが作り直されてしまう)
|
|
616
|
+
this.deleting++;
|
|
617
|
+
return this.inOrder(async () => {
|
|
618
|
+
try {
|
|
619
|
+
const slug = this.slug();
|
|
620
|
+
// 索引を先に消す。ここで失敗したら、ページの内容は残っているので、やり直せる
|
|
621
|
+
await removePage(this.env.DB, slug);
|
|
622
|
+
if (this.version > 0)
|
|
623
|
+
await this.detachFromHierarchy(slug, actor);
|
|
624
|
+
await this.ctx.storage.deleteAlarm();
|
|
625
|
+
await this.ctx.storage.deleteAll();
|
|
626
|
+
this.version = 0;
|
|
627
|
+
// 接続中のブラウザには再接続させない(再接続しても、hello の epoch が合わずに閉じられる)
|
|
628
|
+
for (const ws of this.ctx.getWebSockets())
|
|
629
|
+
ws.close(CLOSE_PAGE_DELETED, 'page deleted');
|
|
630
|
+
}
|
|
631
|
+
finally {
|
|
632
|
+
this.deleting--;
|
|
633
|
+
this.generation++;
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* 書き込みを順番待ちに入れる。待っている間にページの削除が始まるか済んでいたら、
|
|
639
|
+
* 実行せずに `deleted` を返す。
|
|
640
|
+
*/
|
|
641
|
+
write(run, deleted) {
|
|
642
|
+
if (this.deleting > 0)
|
|
643
|
+
return Promise.resolve(deleted);
|
|
644
|
+
const generation = this.generation;
|
|
645
|
+
return this.inOrder(async () => this.deleting === 0 && generation === this.generation ? run() : deleted);
|
|
646
|
+
}
|
|
647
|
+
// --- D1 の索引への反映 ---
|
|
648
|
+
/**
|
|
649
|
+
* 変更から少し後に、まとめて D1 へ反映する(書き込みごとには反映しない)。
|
|
650
|
+
* alarm が設定済みなら何もしない。alarm の実行中は未設定に見えるので、反映中に届いた変更は
|
|
651
|
+
* 次の alarm で反映される。
|
|
652
|
+
*/
|
|
653
|
+
scheduleIndexing() {
|
|
654
|
+
this.ctx.waitUntil(this.ctx.storage.getAlarm().then((alarm) => {
|
|
655
|
+
if (alarm === null)
|
|
656
|
+
return this.ctx.storage.setAlarm(Date.now() + INDEX_DELAY_MS);
|
|
657
|
+
}));
|
|
658
|
+
}
|
|
659
|
+
/** 失敗して例外で終わると、ランタイムが間隔を空けて再実行する */
|
|
660
|
+
async alarm() {
|
|
661
|
+
if (this.version === 0)
|
|
662
|
+
return;
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
this.pruneOps(now);
|
|
665
|
+
await this.reindexInOrder(now);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* D1 の索引へ反映する。書き込みや削除と同じ順番待ちに入れる(索引の書き込みが、ページの削除と
|
|
669
|
+
* 前後しないようにする)
|
|
670
|
+
*/
|
|
671
|
+
reindexInOrder(now) {
|
|
672
|
+
return this.inOrder(async () => {
|
|
673
|
+
if (this.version > 0)
|
|
674
|
+
await this.reindex(now);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
/** 保持期間を過ぎた ops を消す。直近の分は期間を過ぎていても残す */
|
|
678
|
+
pruneOps(now) {
|
|
679
|
+
this.sql.exec(`DELETE FROM ops WHERE created_at < ? AND seq <= ?`, now - OPS_RETENTION_MS, this.getSeq() - OPS_RETENTION_COUNT);
|
|
680
|
+
}
|
|
681
|
+
async reindex(now) {
|
|
682
|
+
const slug = this.slug();
|
|
683
|
+
const title = this.getMeta('title');
|
|
684
|
+
const notes = readNotes(this.sql);
|
|
685
|
+
const row = {
|
|
686
|
+
title,
|
|
687
|
+
color: this.getMeta('color'),
|
|
688
|
+
parent: this.getMeta('parent'),
|
|
689
|
+
layout: buildLayout(notes),
|
|
690
|
+
noteCount: notes.length,
|
|
691
|
+
now,
|
|
692
|
+
};
|
|
693
|
+
const content = await buildPageContent(slug, notes.map((note) => note.text), this.env.SITE_ORIGIN);
|
|
694
|
+
const hash = await contentHash(title, content);
|
|
695
|
+
// 付箋の移動や色の変更では、検索とリンクに関わる内容は変わらない。そのときは全文検索と
|
|
696
|
+
// リンクの索引を書き直さない(ページの行が D1 にないときは作り直す)
|
|
697
|
+
const unchanged = hash === this.getMeta('index_hash');
|
|
698
|
+
if (!unchanged || !(await touchPage(this.env.DB, slug, row))) {
|
|
699
|
+
await writePage(this.env.DB, slug, content, row);
|
|
700
|
+
this.setMeta('index_hash', hash);
|
|
701
|
+
}
|
|
702
|
+
const placed = new Set(notes.map(childPageOf).filter((page) => page !== undefined));
|
|
703
|
+
await this.reconcileChildren(slug, placed);
|
|
704
|
+
await this.healParent(row.parent);
|
|
705
|
+
// 親ページにある自分の付箋の色が、合っていなければ合わせる(合わせ損ねた分と、色をそろえる
|
|
706
|
+
// ようにする前に付けた色)。色を付けていないページでは、付箋の色のままにする
|
|
707
|
+
if (row.color !== null)
|
|
708
|
+
await this.recolorOwnNote('system', false);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 親ページがもうなければ、ルートへ戻る。親ページの削除のときに、clearParent が届かなかった場合に
|
|
712
|
+
* 効く(そのままだと、存在しない親を指したままで、一覧に出ない)。次の索引の更新で、D1 にも反映する
|
|
713
|
+
*/
|
|
714
|
+
async healParent(parent) {
|
|
715
|
+
if (parent === null || (await this.env.PAGE.getByName(parent).getPageRef()) !== null)
|
|
716
|
+
return;
|
|
717
|
+
this.setMeta('parent', null);
|
|
718
|
+
this.scheduleIndexing();
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* 索引の上ではこのページの子なのに、子ページの付箋がないページを、ルートへ戻す。
|
|
722
|
+
* 付箋を消した後の clearParent が失敗した場合の、食い違いを直す
|
|
723
|
+
*/
|
|
724
|
+
async reconcileChildren(slug, placed) {
|
|
725
|
+
const stale = (await indexedChildren(this.env.DB, slug)).filter((page) => !placed.has(page));
|
|
726
|
+
for (const page of stale)
|
|
727
|
+
await this.env.PAGE.getByName(page).clearParent(slug);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* 唯一の書き込み口。ブラウザ(WebSocket)からも MCP からも、これを通して変更する。
|
|
731
|
+
*
|
|
732
|
+
* サニタイズは非同期で、かかる時間が text によって違う。待っている間に後続の呼び出しが
|
|
733
|
+
* 先に保存まで進むと、同じクライアントの操作が送信順に適用されなくなる(作成より先に
|
|
734
|
+
* その付箋の更新が処理されるなど)ので、呼び出しを 1 つずつ順に処理する。
|
|
735
|
+
*/
|
|
736
|
+
applyOps(input) {
|
|
737
|
+
return this.write(() => this.applyOpsInOrder(input), { ok: false, reason: REASON_PAGE_DELETED });
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* 他のページから移す(写す)付箋と、その間の接続線を受け取って置く。互いの位置関係は保ち、
|
|
741
|
+
* 今ある付箋の下に置く(送る側は、このページの付箋の位置を知らない)。
|
|
742
|
+
* ページがまだ作られていなければ、作らずに断る。
|
|
743
|
+
*/
|
|
744
|
+
receiveNotes(input) {
|
|
745
|
+
return this.write(async () => {
|
|
746
|
+
const { notes, edges, from, ...sender } = input;
|
|
747
|
+
if (!Array.isArray(notes) || !Array.isArray(edges)) {
|
|
748
|
+
return { ok: false, reason: 'notes and edges must be arrays' };
|
|
749
|
+
}
|
|
750
|
+
return this.applyOpsInOrder({
|
|
751
|
+
...sender,
|
|
752
|
+
deltas: [
|
|
753
|
+
...notes.map((note) => ({ type: 'note:create', note })),
|
|
754
|
+
...edges.map((edge) => ({ type: 'edge:create', edge })),
|
|
755
|
+
],
|
|
756
|
+
summary: systemSummary.notesReceived(from),
|
|
757
|
+
mustExist: true,
|
|
758
|
+
}, (deltas) => placeBelow(deltas.flatMap((d) => (d.type === 'note:create' ? [d.note] : [])), this.sql
|
|
759
|
+
.exec(`SELECT min(x) AS left, max(y + height) AS bottom FROM notes`)
|
|
760
|
+
.one()));
|
|
761
|
+
},
|
|
762
|
+
// 呼び出し側(Worker)は、ページがないときと同じに扱う
|
|
763
|
+
{ ok: false, reason: REASON_PAGE_NOT_FOUND });
|
|
764
|
+
}
|
|
765
|
+
/** 前の書き込みが終わってから `run` を始める */
|
|
766
|
+
inOrder(run) {
|
|
767
|
+
const result = this.applyQueue.then(run);
|
|
768
|
+
// 結果(大きなデルタを含む)を次の呼び出しまで掴まないよう、値を捨てて繋ぐ
|
|
769
|
+
this.applyQueue = result.then(() => { }, () => { });
|
|
770
|
+
return result;
|
|
771
|
+
}
|
|
772
|
+
/** 同じ送信元の同じ操作が記録済みなら、その行を返す */
|
|
773
|
+
findOp(actor, clientId, opId) {
|
|
774
|
+
return this.sql
|
|
775
|
+
.exec(
|
|
776
|
+
// clientId はクライアントの自己申告なので、主体も合わせて同じ送信元かを判定する
|
|
777
|
+
`SELECT seq, reverts, body, fixups FROM ops
|
|
778
|
+
WHERE actor = ? AND client_id = ? AND op_id = ?`, actor, clientId, opId)
|
|
779
|
+
.toArray()[0];
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* 適用したデルタを ops に記録し、seq を進める。`transactionSync` の中で呼ぶこと。
|
|
783
|
+
* 戻り値の `deliver` は、トランザクションが確定してから呼ぶ(接続中のクライアントへの配信)。
|
|
784
|
+
*/
|
|
785
|
+
recordOp(op, deltas, fixups, now) {
|
|
786
|
+
const body = JSON.stringify(deltas);
|
|
787
|
+
const fixupsJson = fixups.length > 0 ? JSON.stringify(fixups) : null;
|
|
788
|
+
if (exceedsBytes(body + (fixupsJson ?? ''), MAX_OP_BYTES)) {
|
|
789
|
+
throw new RejectError('operation too large');
|
|
790
|
+
}
|
|
791
|
+
const summary = op.summary?.slice(0, MAX_SUMMARY_LENGTH);
|
|
792
|
+
const seq = this.getSeq() + 1;
|
|
793
|
+
this.setMeta('seq', String(seq));
|
|
794
|
+
this.sql.exec(`INSERT INTO ops (seq, actor, client_id, op_id, summary, reverts, created_at, body, fixups)
|
|
795
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, seq, op.actor, op.clientId, op.opId, summary ?? null, op.reverts ?? null, now, body, fixupsJson);
|
|
796
|
+
const head = {
|
|
797
|
+
type: 'ops', seq, actor: op.actor, clientId: op.clientId, opId: op.opId, createdAt: now, summary,
|
|
798
|
+
reverts: op.reverts,
|
|
799
|
+
};
|
|
800
|
+
const deliver = () => {
|
|
801
|
+
// fixups は送信元にだけ付ける
|
|
802
|
+
this.broadcast(opsJson(head, body, null), {
|
|
803
|
+
actor: op.actor,
|
|
804
|
+
clientId: op.clientId,
|
|
805
|
+
toSender: opsJson(head, body, fixupsJson),
|
|
806
|
+
});
|
|
807
|
+
this.scheduleIndexing();
|
|
808
|
+
};
|
|
809
|
+
return { seq, deliver };
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* @param prepare 検証の済んだデルタを、適用する前に書き換える(今あるボードに合わせた位置決めなど)。
|
|
813
|
+
* 同じ操作の再送(適用済み)では、呼ばない
|
|
814
|
+
* @param matchColors 適用した後に、子ページの色と、その付箋の色をそろえる(matchChildColors)
|
|
815
|
+
*/
|
|
816
|
+
async applyOpsInOrder(input, prepare, matchColors = true) {
|
|
817
|
+
let deltas;
|
|
818
|
+
let sanitized;
|
|
819
|
+
try {
|
|
820
|
+
parseId(input.clientId, 'clientId');
|
|
821
|
+
parseId(input.opId, 'opId');
|
|
822
|
+
deltas = parseDeltas(input.deltas);
|
|
823
|
+
sanitized = await sanitizeDeltas(deltas);
|
|
824
|
+
}
|
|
825
|
+
catch (e) {
|
|
826
|
+
if (e instanceof RejectError || e instanceof SanitizeError) {
|
|
827
|
+
return { ok: false, reason: e.message };
|
|
828
|
+
}
|
|
829
|
+
throw e;
|
|
830
|
+
}
|
|
831
|
+
if (input.mustExist && this.version === 0)
|
|
832
|
+
return { ok: false, reason: REASON_PAGE_NOT_FOUND };
|
|
833
|
+
this.migrate();
|
|
834
|
+
// 再接続後の再送。適用済みの結果をそのまま返す
|
|
835
|
+
const done = this.findOp(input.actor, input.clientId, input.opId);
|
|
836
|
+
if (done) {
|
|
837
|
+
return {
|
|
838
|
+
ok: true,
|
|
839
|
+
seq: done.seq,
|
|
840
|
+
deltas: JSON.parse(done.body),
|
|
841
|
+
fixups: done.fixups ? JSON.parse(done.fixups) : [],
|
|
842
|
+
broadcast: false,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
prepare?.(deltas);
|
|
846
|
+
const apply = () => {
|
|
847
|
+
let deliver;
|
|
848
|
+
try {
|
|
849
|
+
const result = this.ctx.storage.transactionSync(() => {
|
|
850
|
+
const now = Date.now();
|
|
851
|
+
const applied = applyDeltas(this.sql, deltas, input.actor, now, sanitized.cleanBefore);
|
|
852
|
+
const fixups = [...sanitized.fixups, ...applied.fixups];
|
|
853
|
+
if (applied.deltas.length === 0) {
|
|
854
|
+
return { ok: true, seq: this.getSeq(), deltas: [], fixups, broadcast: false };
|
|
855
|
+
}
|
|
856
|
+
const recorded = this.recordOp(input, applied.deltas, fixups, now);
|
|
857
|
+
deliver = recorded.deliver;
|
|
858
|
+
return { ok: true, seq: recorded.seq, deltas: applied.deltas, fixups, broadcast: true };
|
|
859
|
+
});
|
|
860
|
+
// トランザクションが確定してから配信する。保存と同じ同期の区間なので、順序は seq の順になる
|
|
861
|
+
deliver?.();
|
|
862
|
+
return result;
|
|
863
|
+
}
|
|
864
|
+
catch (e) {
|
|
865
|
+
if (e instanceof RejectError)
|
|
866
|
+
return { ok: false, reason: e.message, current: e.current };
|
|
867
|
+
throw e;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const result = await this.withChildren(deltas, apply, (reason) => ({ ok: false, reason }));
|
|
871
|
+
// 同じ操作の再送と、何も変えなかった操作では、そろえ直さない
|
|
872
|
+
if (matchColors && result.ok && result.broadcast)
|
|
873
|
+
await this.matchChildColors(result.deltas, input.actor);
|
|
874
|
+
return result;
|
|
875
|
+
}
|
|
876
|
+
// --- 取り消しと履歴 ---
|
|
877
|
+
/**
|
|
878
|
+
* 最近の操作を新しい順に返す。`agentOnly` なら agent(MCP 経由)の操作に、
|
|
879
|
+
* `actor` を指定するとその主体の操作に絞る。
|
|
880
|
+
*/
|
|
881
|
+
listOps(options = {}) {
|
|
882
|
+
if (this.version === 0)
|
|
883
|
+
return [];
|
|
884
|
+
return this.sql
|
|
885
|
+
.exec(`SELECT seq, actor, summary, reverts, reverted_by, created_at FROM ops
|
|
886
|
+
WHERE (?1 = 0 OR substr(actor, 1, 6) = 'agent:') AND (?2 IS NULL OR actor = ?2)
|
|
887
|
+
ORDER BY seq DESC LIMIT ?3`, options.agentOnly ? 1 : 0, options.actor ?? null, clampLimit(options.limit, DEFAULT_LIST_OPS, MAX_LIST_OPS))
|
|
888
|
+
.toArray()
|
|
889
|
+
.map((row) => ({
|
|
890
|
+
seq: row.seq,
|
|
891
|
+
actor: row.actor,
|
|
892
|
+
summary: row.summary,
|
|
893
|
+
reverts: row.reverts,
|
|
894
|
+
revertedBy: row.reverted_by,
|
|
895
|
+
createdAt: row.created_at,
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* 記録済みの操作を取り消す。逆向きのデルタを新しい操作として適用する。
|
|
900
|
+
* その後に変更された付箋や接続線は取り消さず、`skipped` で返す(部分適用)。
|
|
901
|
+
*/
|
|
902
|
+
revert(input) {
|
|
903
|
+
return this.write(() => this.revertInOrder(input), {
|
|
904
|
+
ok: false, code: 'not-found', reason: REASON_PAGE_DELETED,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
async revertInOrder(input) {
|
|
908
|
+
const fail = (code, reason) => ({ ok: false, code, reason });
|
|
909
|
+
const notFound = () => fail('not-found', 'operation not found');
|
|
910
|
+
const { seq: target, actor, clientId, opId } = input;
|
|
911
|
+
try {
|
|
912
|
+
parseId(clientId, 'clientId');
|
|
913
|
+
parseId(opId, 'opId');
|
|
914
|
+
if (this.version === 0 || !Number.isInteger(target))
|
|
915
|
+
return notFound();
|
|
916
|
+
const done = this.findOp(actor, clientId, opId);
|
|
917
|
+
if (done) {
|
|
918
|
+
// 同じ opId を別の操作に使い回した場合。成功として返すと、実行されていないのに
|
|
919
|
+
// 取り消せたように見える
|
|
920
|
+
if (done.reverts !== target)
|
|
921
|
+
return fail('invalid', 'opId already used');
|
|
922
|
+
// 再送。記録済みの結果を返す(skipped は残していない)
|
|
923
|
+
const applied = this.sql
|
|
924
|
+
.exec(`SELECT json_array_length(body) AS n FROM ops WHERE seq = ?`, done.seq)
|
|
925
|
+
.one().n;
|
|
926
|
+
return { ok: true, seq: done.seq, applied, skipped: [] };
|
|
927
|
+
}
|
|
928
|
+
// 記録済みの body は変わらないので、トランザクションの前に読んでよい
|
|
929
|
+
const row = this.sql.exec(`SELECT body FROM ops WHERE seq = ?`, target).toArray()[0];
|
|
930
|
+
if (!row)
|
|
931
|
+
return notFound();
|
|
932
|
+
const inverse = invertDeltas(JSON.parse(row.body));
|
|
933
|
+
// 戻す text も、今のサニタイズの規則を通す
|
|
934
|
+
await sanitizeDeltas(inverse, { cleanBefore: false });
|
|
935
|
+
/** 適用して記録したデルタ */
|
|
936
|
+
let reverted = [];
|
|
937
|
+
const apply = () => {
|
|
938
|
+
let deliver;
|
|
939
|
+
const result = this.ctx.storage.transactionSync(() => {
|
|
940
|
+
// 対象の状態は、サニタイズを待った後のここで確かめる
|
|
941
|
+
const op = this.sql.exec(`SELECT actor, reverted_by FROM ops WHERE seq = ?`, target).toArray()[0];
|
|
942
|
+
if (!op)
|
|
943
|
+
return notFound();
|
|
944
|
+
if (op.reverted_by !== null)
|
|
945
|
+
return fail('conflict', 'already reverted');
|
|
946
|
+
if (input.ownOnly && op.actor !== actor)
|
|
947
|
+
return fail('forbidden', 'not your operation');
|
|
948
|
+
const now = Date.now();
|
|
949
|
+
const { deltas, skipped } = applyRevert(this.sql, inverse, actor, now);
|
|
950
|
+
if (deltas.length === 0)
|
|
951
|
+
return { ok: true, seq: null, applied: 0, skipped };
|
|
952
|
+
reverted = deltas;
|
|
953
|
+
const summary = input.summary ?? systemSummary.revert(target);
|
|
954
|
+
const recorded = this.recordOp({ actor, clientId, opId, summary, reverts: target }, deltas, [], now);
|
|
955
|
+
// 取り消しを取り消した場合は、元の操作の内容が戻るので、元の操作をもう一度取り消せるようにする
|
|
956
|
+
this.sql.exec(`UPDATE ops SET reverted_by = NULL WHERE reverted_by = ?`, target);
|
|
957
|
+
this.sql.exec(`UPDATE ops SET reverted_by = ? WHERE seq = ?`, recorded.seq, target);
|
|
958
|
+
deliver = recorded.deliver;
|
|
959
|
+
return { ok: true, seq: recorded.seq, applied: deltas.length, skipped };
|
|
960
|
+
});
|
|
961
|
+
deliver?.();
|
|
962
|
+
return result;
|
|
963
|
+
};
|
|
964
|
+
// 取り消しで子ページの付箋が戻るなら、もう一度子にする。置けなければ、取り消しを断る
|
|
965
|
+
const result = await this.withChildren(inverse, apply, (reason) => fail('conflict', reason));
|
|
966
|
+
await this.matchChildColors(reverted, actor);
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
catch (e) {
|
|
970
|
+
if (e instanceof RejectError || e instanceof SanitizeError)
|
|
971
|
+
return fail('invalid', e.message);
|
|
972
|
+
throw e;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
// --- WebSocket ---
|
|
976
|
+
/** Worker が認証を済ませ、主体を ACTOR_HEADER に入れて転送してくる */
|
|
977
|
+
async fetch(request) {
|
|
978
|
+
const actor = request.headers.get(ACTOR_HEADER);
|
|
979
|
+
if (!actor)
|
|
980
|
+
return new Response('missing actor', { status: 400 });
|
|
981
|
+
const expires = request.headers.get(AUTH_EXPIRES_HEADER);
|
|
982
|
+
const expiresAt = expires === null ? undefined : Number(expires);
|
|
983
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
984
|
+
this.ctx.acceptWebSocket(server);
|
|
985
|
+
server.serializeAttachment({ actor, expiresAt });
|
|
986
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
987
|
+
}
|
|
988
|
+
async webSocketMessage(ws, message) {
|
|
989
|
+
if (typeof message !== 'string' || message.length > MAX_MESSAGE_LENGTH) {
|
|
990
|
+
ws.close(1009, 'message too large');
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
let msg;
|
|
994
|
+
try {
|
|
995
|
+
msg = JSON.parse(message);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
ws.close(1007, 'invalid json');
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (typeof msg !== 'object' || msg === null)
|
|
1002
|
+
return;
|
|
1003
|
+
const attachment = ws.deserializeAttachment();
|
|
1004
|
+
if (isExpired(attachment)) {
|
|
1005
|
+
ws.close(CLOSE_AUTH_EXPIRED, 'authentication expired');
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (msg.type === 'hello') {
|
|
1009
|
+
this.onHello(ws, attachment, msg);
|
|
1010
|
+
}
|
|
1011
|
+
else if (msg.type === 'ops') {
|
|
1012
|
+
await this.onOps(ws, attachment, msg);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/** 接続直後の同期。`lastSeq` 以降の ops が残っていれば差分を、なければスナップショットを返す */
|
|
1016
|
+
onHello(ws, attachment, msg) {
|
|
1017
|
+
let clientId;
|
|
1018
|
+
try {
|
|
1019
|
+
clientId = parseId(msg.clientId, 'clientId');
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
ws.close(1008, 'invalid clientId');
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
// クライアントが前に見たページと、今のページが別物(削除された、または削除して作り直された)
|
|
1026
|
+
// なら、接続を閉じる。そのまま続けると、手元の未確定の操作で、消したページの内容が書き戻される
|
|
1027
|
+
const epoch = this.version > 0 ? this.getMeta('epoch') : null;
|
|
1028
|
+
if (typeof msg.epoch === 'string' && msg.epoch !== epoch) {
|
|
1029
|
+
ws.close(CLOSE_PAGE_DELETED, 'page deleted');
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
// これ以降、この接続は配信の対象になる
|
|
1033
|
+
ws.serializeAttachment({ ...attachment, clientId });
|
|
1034
|
+
const { lastSeq } = msg;
|
|
1035
|
+
if (!this.canReplay(lastSeq)) {
|
|
1036
|
+
send(ws, JSON.stringify({ type: 'snapshot', ...this.getSnapshot() }));
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
// 表示名の変更は seq を進めないので、切断中に変わっていても差分には出ない。現在値を送る
|
|
1040
|
+
send(ws, this.metaJson());
|
|
1041
|
+
const rows = this.sql.exec(`SELECT seq, actor, client_id, op_id, summary, reverts, created_at, body, fixups FROM ops
|
|
1042
|
+
WHERE seq > ? ORDER BY seq`, lastSeq);
|
|
1043
|
+
for (const row of rows) {
|
|
1044
|
+
const head = {
|
|
1045
|
+
type: 'ops',
|
|
1046
|
+
seq: row.seq,
|
|
1047
|
+
actor: row.actor,
|
|
1048
|
+
clientId: row.client_id,
|
|
1049
|
+
opId: row.op_id,
|
|
1050
|
+
createdAt: row.created_at,
|
|
1051
|
+
summary: row.summary ?? undefined,
|
|
1052
|
+
reverts: row.reverts ?? undefined,
|
|
1053
|
+
};
|
|
1054
|
+
// 自分の操作の確定を受け取る前に切断していた場合は、fixups も返す
|
|
1055
|
+
const own = head.clientId === clientId && head.actor === attachment.actor;
|
|
1056
|
+
const fixups = own ? row.fixups : null;
|
|
1057
|
+
send(ws, opsJson(head, row.body, fixups));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
/** `lastSeq` 以降の変更を、差分(ops の再送)で返せるか */
|
|
1061
|
+
canReplay(lastSeq) {
|
|
1062
|
+
// 書き込みのないページにはテーブルがない。空のスナップショットを返す
|
|
1063
|
+
if (this.version === 0)
|
|
1064
|
+
return false;
|
|
1065
|
+
const seq = this.getSeq();
|
|
1066
|
+
if (typeof lastSeq !== 'number' || !Number.isInteger(lastSeq) || lastSeq < 0 || lastSeq > seq) {
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
const missed = seq - lastSeq;
|
|
1070
|
+
if (missed === 0)
|
|
1071
|
+
return true;
|
|
1072
|
+
if (missed > MAX_REPLAY_OPS)
|
|
1073
|
+
return false;
|
|
1074
|
+
const retained = this.sql
|
|
1075
|
+
.exec(`SELECT count(*) AS count, sum(length(body) + length(coalesce(fixups, ''))) AS size
|
|
1076
|
+
FROM ops WHERE seq > ?`, lastSeq)
|
|
1077
|
+
.one();
|
|
1078
|
+
return retained.count === missed && retained.size <= MAX_REPLAY_LENGTH;
|
|
1079
|
+
}
|
|
1080
|
+
async onOps(ws, attachment, msg) {
|
|
1081
|
+
const { actor, clientId } = attachment;
|
|
1082
|
+
if (!clientId) {
|
|
1083
|
+
ws.close(1008, 'hello required');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const opId = typeof msg.opId === 'string' ? msg.opId : '';
|
|
1087
|
+
let result;
|
|
1088
|
+
try {
|
|
1089
|
+
result = await this.applyOps({ actor, clientId, opId, deltas: msg.deltas });
|
|
1090
|
+
}
|
|
1091
|
+
catch (e) {
|
|
1092
|
+
// 想定外の失敗でも送信元に結果を返す(返さないと、その操作が未確定のまま残る)
|
|
1093
|
+
console.error('applyOps failed', e);
|
|
1094
|
+
result = { ok: false, reason: 'internal error' };
|
|
1095
|
+
}
|
|
1096
|
+
if (!result.ok) {
|
|
1097
|
+
const { reason, current } = result;
|
|
1098
|
+
const fixups = current && contentToDeltas(current);
|
|
1099
|
+
send(ws, JSON.stringify({ type: 'reject', opId, reason, fixups }));
|
|
1100
|
+
}
|
|
1101
|
+
else if (!result.broadcast) {
|
|
1102
|
+
// 配信していない結果(再送、すべて捨てた操作)は、送信元にだけ確定を返す
|
|
1103
|
+
const { seq, deltas, fixups } = result;
|
|
1104
|
+
send(ws, JSON.stringify({ type: 'ops', seq, actor, clientId, opId, deltas, fixups }));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* hello を済ませた全接続へ送る。`sender` を指定すると、その送信元の接続にだけ
|
|
1109
|
+
* `toSender` を送る(null なら送らない)。clientId はクライアントの自己申告なので、
|
|
1110
|
+
* 送信元かどうかは主体も合わせて判定する
|
|
1111
|
+
*/
|
|
1112
|
+
broadcast(json, sender) {
|
|
1113
|
+
for (const ws of this.ctx.getWebSockets()) {
|
|
1114
|
+
const attachment = ws.deserializeAttachment();
|
|
1115
|
+
if (isExpired(attachment)) {
|
|
1116
|
+
ws.close(CLOSE_AUTH_EXPIRED, 'authentication expired');
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (!attachment.clientId)
|
|
1120
|
+
continue;
|
|
1121
|
+
const out = sender && attachment.clientId === sender.clientId && attachment.actor === sender.actor
|
|
1122
|
+
? sender.toSender
|
|
1123
|
+
: json;
|
|
1124
|
+
if (out !== null)
|
|
1125
|
+
send(ws, out);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
/** 順番を待っている間にページが削除された書き込みに返す理由 */
|
|
1130
|
+
const REASON_PAGE_DELETED = 'page deleted';
|
|
1131
|
+
/** 表示名を検証する。空文字は未設定(null)。不正なら undefined */
|
|
1132
|
+
function parseTitle(input) {
|
|
1133
|
+
if (typeof input !== 'string')
|
|
1134
|
+
return undefined;
|
|
1135
|
+
const title = input.trim();
|
|
1136
|
+
// 制御文字(改行を含む)は表示と一覧を崩すので受け付けない
|
|
1137
|
+
if (title.length > MAX_TITLE_LENGTH || /[\u0000-\u001f\u007f]/.test(title))
|
|
1138
|
+
return undefined;
|
|
1139
|
+
return title || null;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* 付箋と接続線を、その内容に合わせるための更新デルタにする(拒否した操作の送信元に、
|
|
1143
|
+
* サーバーの現在値を伝えるのに使う)。同期しない zIndex と、変更できない from / to は含めない
|
|
1144
|
+
*/
|
|
1145
|
+
function contentToDeltas(content) {
|
|
1146
|
+
return [
|
|
1147
|
+
...content.notes.map(({ id, zIndex: _zIndex, ...after }) => ({
|
|
1148
|
+
type: 'note:update', noteId: id, before: {}, after: { ...UNSET_FLAGS, ...after },
|
|
1149
|
+
})),
|
|
1150
|
+
...content.edges.map(({ id, from: _from, to: _to, ...after }) => ({
|
|
1151
|
+
type: 'edge:update', edgeId: id, before: {}, after,
|
|
1152
|
+
})),
|
|
1153
|
+
];
|
|
1154
|
+
}
|
|
1155
|
+
/** 保存済みの JSON(deltas、fixups)を解析し直さずに ops メッセージを組み立てる */
|
|
1156
|
+
function opsJson(head, body, fixupsJson) {
|
|
1157
|
+
const fixups = fixupsJson ? `,"fixups":${fixupsJson}` : '';
|
|
1158
|
+
return `${JSON.stringify(head).slice(0, -1)},"deltas":${body}${fixups}}`;
|
|
1159
|
+
}
|
|
1160
|
+
/** 閉じかけの接続への送信は失敗するが、相手は再接続時に差分を受け取るので無視してよい */
|
|
1161
|
+
function send(ws, json) {
|
|
1162
|
+
try {
|
|
1163
|
+
ws.send(json);
|
|
1164
|
+
}
|
|
1165
|
+
catch {
|
|
1166
|
+
// 無視する
|
|
1167
|
+
}
|
|
1168
|
+
}
|