@panphora/clayjs 1.13.0 → 1.14.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69739 -41784
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +95 -94
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/plugins/visitor-drafts/prompt.js +4 -6
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { parse } from 'parse5';
|
|
2
|
+
import { parseJsonRelaxed } from 'hyper-morph/json-parse';
|
|
3
|
+
import { cloneJson, formatBy, serializeLive, stampOf, textHash, unionRanges } from '../rooms/index.js';
|
|
4
|
+
import { blocksFromYValue } from '../../page/ybind/decode.js';
|
|
5
|
+
import { YDecodeError } from '../../page/ybind/provider.js';
|
|
6
|
+
import { canon, fragmentCleaner, renderBlocks } from '../../page/projection.js';
|
|
7
|
+
import { classifyFile, readBack } from '../../page/adopt.js';
|
|
8
|
+
import { merge3, mergeJson } from 'hyper-morph';
|
|
9
|
+
import { mergePageModels } from '../../page/merge.js';
|
|
10
|
+
|
|
11
|
+
const PAGE = /^page(\.[0-9]+)?$/;
|
|
12
|
+
const NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
13
|
+
const attr = (node, name) => node.attrs.find((item) => item.name === name)?.value;
|
|
14
|
+
const escapeAttr = (value) => value.replace(/&/g, '&').replace(/"/g, '"');
|
|
15
|
+
|
|
16
|
+
function provenanceOf(room, store) {
|
|
17
|
+
const frames = unionRanges(store.log.filter((entry) => entry.from && entry.seq <= room.seq)
|
|
18
|
+
.map((entry) => [entry.from, [[entry.n, entry.n]]]));
|
|
19
|
+
if (!frames.length || !Number.isSafeInteger(room.seq + 1)) return null;
|
|
20
|
+
return formatBy({ v: 2, frames, ancestors: [], capture: [`checkpoint_${textHash(room.epoch)}`, room.seq + 1] });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function captureCheckpoint(room, { nodeId, roomKey }) {
|
|
24
|
+
if (!room || room.key !== roomKey || String(room.nodeId) !== String(nodeId)) throw new TypeError('The room identity changed');
|
|
25
|
+
const { epoch, seq } = room;
|
|
26
|
+
const generation = room.pageTransition?.id ?? null;
|
|
27
|
+
const stores = [...room.stores].filter(([, store]) => !store.ephemeral).map(([name, store]) => {
|
|
28
|
+
const text = serializeLive(store.value);
|
|
29
|
+
return { name, value: cloneJson(store.value), text, liveAt: stampOf(epoch, seq, text), provenance: provenanceOf(room, store) };
|
|
30
|
+
});
|
|
31
|
+
return { roomKey, nodeId, epoch, generation, seq, stores, adopted: room.adopted ?? null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function inspect(html) {
|
|
35
|
+
const root = parse(html, { sourceCodeLocationInfo: true });
|
|
36
|
+
const tags = new Map();
|
|
37
|
+
const duplicates = new Set();
|
|
38
|
+
const articles = [];
|
|
39
|
+
function visit(node, inert = false) {
|
|
40
|
+
if (!node.tagName) { for (const child of node.childNodes ?? []) visit(child, inert); return; }
|
|
41
|
+
const blocked = inert || node.tagName === 'template' || node.tagName === 'noscript';
|
|
42
|
+
if (!blocked && node.tagName === 'article' && attr(node, 'data-page') !== undefined) articles.push(node);
|
|
43
|
+
if (!blocked && node.tagName === 'script' && attr(node, 'src') === undefined &&
|
|
44
|
+
(attr(node, 'clay') ?? '').split(/\s+/).includes('live')) {
|
|
45
|
+
const type = (attr(node, 'type') ?? '').split(';')[0].trim().toLowerCase();
|
|
46
|
+
const name = attr(node, 'merge');
|
|
47
|
+
if (name && NAME.test(name) && (type === 'application/json' || type.endsWith('+json'))) {
|
|
48
|
+
if (tags.has(name)) duplicates.add(name);
|
|
49
|
+
else tags.set(name, node);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const child of node.childNodes ?? []) visit(child, blocked);
|
|
53
|
+
if (node.content) visit(node.content, blocked);
|
|
54
|
+
}
|
|
55
|
+
visit(root);
|
|
56
|
+
for (const name of duplicates) tags.delete(name);
|
|
57
|
+
return { article: articles.length === 1 ? articles[0] : null, tags };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startTag(html, node, store) {
|
|
61
|
+
const loc = node.sourceCodeLocation.startTag;
|
|
62
|
+
const removed = Object.entries(node.sourceCodeLocation.attrs ?? {})
|
|
63
|
+
.filter(([name]) => name === 'live-at' || name === 'live-by')
|
|
64
|
+
.map(([, range]) => range).sort((a, b) => b.startOffset - a.startOffset);
|
|
65
|
+
let raw = html.slice(loc.startOffset, loc.endOffset);
|
|
66
|
+
for (const range of removed) raw = raw.slice(0, range.startOffset - loc.startOffset) + raw.slice(range.endOffset - loc.startOffset);
|
|
67
|
+
const insert = ` live-at="${escapeAttr(store.liveAt)}"${store.provenance ? ` live-by="${escapeAttr(store.provenance)}"` : ''}`;
|
|
68
|
+
return raw.slice(0, -1) + insert + '>';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function projection(capture) {
|
|
72
|
+
const page = capture.stores.find((store) => store.name === 'page');
|
|
73
|
+
if (!page || capture.stores.some((store) => PAGE.test(store.name) && store.name !== 'page')) return { kind: 'refused', reason: 'invalid-document' };
|
|
74
|
+
try {
|
|
75
|
+
const blocks = blocksFromYValue(page.value);
|
|
76
|
+
if (Object.values(blocks).some((block) => block.t === 'tool' || (block.t === 'table' && !Object.keys(block.cols ?? {}).length))) {
|
|
77
|
+
return { kind: 'refused', reason: 'unsupported-projection' };
|
|
78
|
+
}
|
|
79
|
+
return { kind: 'ok', blocks };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error instanceof YDecodeError) {
|
|
82
|
+
return { kind: 'refused', reason: error.code === 'incomplete-update' || error.code === 'unresolved-update' ? 'incomplete-page'
|
|
83
|
+
: error.code === 'unsupported-content' ? 'unknown-page-content' : 'invalid-document' };
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function placements(html, capture) {
|
|
90
|
+
const { article, tags } = inspect(html);
|
|
91
|
+
if (!article?.sourceCodeLocation?.startTag || !article.sourceCodeLocation.endTag ||
|
|
92
|
+
capture.stores.some((store) => !tags.get(store.name)?.sourceCodeLocation?.endTag ||
|
|
93
|
+
(attr(tags.get(store.name), 'clay') ?? '').split(/\s+/).includes('ephemeral'))) return null;
|
|
94
|
+
const articleStart = article.sourceCodeLocation.startOffset;
|
|
95
|
+
const articleEnd = article.sourceCodeLocation.endOffset;
|
|
96
|
+
if (capture.stores.some((store) => {
|
|
97
|
+
const tag = tags.get(store.name).sourceCodeLocation;
|
|
98
|
+
return tag.startOffset < articleEnd && tag.endOffset > articleStart;
|
|
99
|
+
})) return null;
|
|
100
|
+
return { article, tags };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replace(html, edits) {
|
|
104
|
+
return edits.sort((a, b) => b.start - a.start).reduce((out, edit) =>
|
|
105
|
+
out.slice(0, edit.start) + edit.text + out.slice(edit.end), html);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Whether the room's blocks already carry everything the hand-edited article
|
|
109
|
+
// changed since its tag was written: merging those changes in changes nothing.
|
|
110
|
+
function holdsEdit(found, blocks, doc) {
|
|
111
|
+
const current = readBack(blocks, doc);
|
|
112
|
+
const merged = mergePageModels(found.baseItems, current, found.parsed, { doc, merge3, mergeJson });
|
|
113
|
+
return canon(merged.items) === canon(current);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Whether a page document (its page tag included) holds an article edited
|
|
117
|
+
// outside the editor since the tag was written, so open tabs must adopt it.
|
|
118
|
+
// A save asks this several times of the same document (before the write, for
|
|
119
|
+
// the recovered copy, for the adoption offer): the last answer is kept.
|
|
120
|
+
let lastAdoption = { html: null, answer: false };
|
|
121
|
+
|
|
122
|
+
export function needsAdoption(html, { createDocument }) {
|
|
123
|
+
if (typeof html !== 'string' || typeof createDocument !== 'function') return false;
|
|
124
|
+
if (lastAdoption.html === html) return lastAdoption.answer;
|
|
125
|
+
const answer = adoptionNeeded(html, createDocument);
|
|
126
|
+
lastAdoption = { html, answer };
|
|
127
|
+
return answer;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function adoptionNeeded(html, createDocument) {
|
|
131
|
+
const { article: node, tags } = inspect(html);
|
|
132
|
+
const loc = node?.sourceCodeLocation;
|
|
133
|
+
if (!loc?.startTag || !loc.endTag) return false;
|
|
134
|
+
const pageNodes = [...tags].filter(([name]) => PAGE.test(name)).map(([, tag]) => tag);
|
|
135
|
+
if (!pageNodes.length) return false;
|
|
136
|
+
const doc = createDocument();
|
|
137
|
+
try {
|
|
138
|
+
const values = pageNodes.map((tag) => {
|
|
139
|
+
const at = tag.sourceCodeLocation;
|
|
140
|
+
return parseJsonRelaxed(html.slice(at.startTag.endOffset, at.endTag.startOffset));
|
|
141
|
+
});
|
|
142
|
+
const article = doc.createElement('article');
|
|
143
|
+
const revision = attr(node, 'data-rev');
|
|
144
|
+
if (revision !== undefined) article.setAttribute('data-rev', revision);
|
|
145
|
+
article.innerHTML = html.slice(loc.startTag.endOffset, loc.endTag.startOffset);
|
|
146
|
+
return classifyFile(article, values).kind === 'adopt';
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
} finally {
|
|
150
|
+
doc.defaultView?.close?.();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// recovery: render the room's blocks even over an article it has not merged, for
|
|
155
|
+
// a recovered copy of its edits (the file itself keeps the outside edit).
|
|
156
|
+
export function renderCheckpoint(currentHtml, capture, { createDocument, recovery = false }) {
|
|
157
|
+
const placement = placements(currentHtml, capture);
|
|
158
|
+
if (!placement || typeof createDocument !== 'function') return { kind: 'refused', reason: 'invalid-document' };
|
|
159
|
+
const decoded = projection(capture);
|
|
160
|
+
if (decoded.kind === 'refused') return decoded;
|
|
161
|
+
const doc = createDocument();
|
|
162
|
+
if (!doc?.createElement) return { kind: 'refused', reason: 'invalid-document' };
|
|
163
|
+
let rendered;
|
|
164
|
+
try {
|
|
165
|
+
const article = doc.createElement('article');
|
|
166
|
+
const articleLoc = placement.article.sourceCodeLocation;
|
|
167
|
+
const revision = attr(placement.article, 'data-rev');
|
|
168
|
+
if (revision !== undefined) article.setAttribute('data-rev', revision);
|
|
169
|
+
article.innerHTML = currentHtml.slice(articleLoc.startTag.endOffset, articleLoc.endTag.startOffset);
|
|
170
|
+
const pageValues = [...placement.tags].filter(([name]) => PAGE.test(name)).map(([, node]) => {
|
|
171
|
+
const loc = node.sourceCodeLocation;
|
|
172
|
+
return parseJsonRelaxed(currentHtml.slice(loc.startTag.endOffset, loc.endTag.startOffset));
|
|
173
|
+
});
|
|
174
|
+
const found = classifyFile(article, pageValues);
|
|
175
|
+
// A tab that merged this article says so on the frame that carried the
|
|
176
|
+
// merge; otherwise the blocks must show the edit.
|
|
177
|
+
if (!recovery && found.kind === 'adopt' && capture.adopted !== found.A && !holdsEdit(found, decoded.blocks, doc)) return { kind: 'refused', reason: 'needs-adoption' };
|
|
178
|
+
rendered = renderBlocks(decoded.blocks, { clean: fragmentCleaner(doc) });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof YDecodeError || error instanceof SyntaxError) return { kind: 'refused', reason: 'invalid-document' };
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
finally { doc.defaultView?.close?.(); }
|
|
185
|
+
const edits = [];
|
|
186
|
+
for (const store of capture.stores) {
|
|
187
|
+
const loc = placement.tags.get(store.name).sourceCodeLocation;
|
|
188
|
+
edits.push({ start: loc.startTag.startOffset, end: loc.startTag.endOffset, text: startTag(currentHtml, placement.tags.get(store.name), store) });
|
|
189
|
+
edits.push({ start: loc.startTag.endOffset, end: loc.endTag.startOffset, text: store.text });
|
|
190
|
+
}
|
|
191
|
+
const articleLoc = placement.article.sourceCodeLocation;
|
|
192
|
+
const articleOpen = currentHtml.slice(articleLoc.startTag.startOffset, articleLoc.startTag.endOffset);
|
|
193
|
+
const rev = ` data-rev="${rendered.rev}"`;
|
|
194
|
+
const revLoc = articleLoc.attrs?.['data-rev'];
|
|
195
|
+
const articleStart = revLoc ? articleOpen.slice(0, revLoc.startOffset - articleLoc.startTag.startOffset) +
|
|
196
|
+
articleOpen.slice(revLoc.endOffset - articleLoc.startTag.startOffset) : articleOpen;
|
|
197
|
+
edits.push({ start: articleLoc.startTag.startOffset, end: articleLoc.startTag.endOffset, text: articleStart.slice(0, -1) + rev + '>' });
|
|
198
|
+
edits.push({ start: articleLoc.startTag.endOffset, end: articleLoc.endTag.startOffset, text: rendered.html });
|
|
199
|
+
return { kind: 'ok', html: replace(currentHtml, edits), revision: rendered.rev };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isCheckpointCurrent(currentHtml, capture) {
|
|
203
|
+
const placement = placements(currentHtml, capture);
|
|
204
|
+
if (!placement) return false;
|
|
205
|
+
const decoded = projection(capture);
|
|
206
|
+
if (decoded.kind === 'refused') return false;
|
|
207
|
+
const rendered = renderBlocks(decoded.blocks);
|
|
208
|
+
const articleLoc = placement.article.sourceCodeLocation;
|
|
209
|
+
if (attr(placement.article, 'data-rev') !== rendered.rev ||
|
|
210
|
+
currentHtml.slice(articleLoc.startTag.endOffset, articleLoc.endTag.startOffset) !== rendered.html) return false;
|
|
211
|
+
return capture.stores.every((store) => {
|
|
212
|
+
const node = placement.tags.get(store.name);
|
|
213
|
+
const loc = node.sourceCodeLocation;
|
|
214
|
+
try {
|
|
215
|
+
return canon(parseJsonRelaxed(currentHtml.slice(loc.startTag.endOffset, loc.endTag.startOffset))) === canon(store.value);
|
|
216
|
+
} catch { return false; }
|
|
217
|
+
});
|
|
218
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export {
|
|
2
|
+
parsePath,
|
|
3
|
+
cloneJson,
|
|
4
|
+
validateOp,
|
|
5
|
+
checkOp,
|
|
6
|
+
applyOp,
|
|
7
|
+
applyOps,
|
|
8
|
+
applyTracked,
|
|
9
|
+
undoTracked,
|
|
10
|
+
sizeChange,
|
|
11
|
+
coalesce,
|
|
12
|
+
overlaps,
|
|
13
|
+
assignDeep,
|
|
14
|
+
serializeLive,
|
|
15
|
+
textHash,
|
|
16
|
+
stampOf,
|
|
17
|
+
parseStamp,
|
|
18
|
+
diffOps,
|
|
19
|
+
} from './live-data-ops.js';
|
|
20
|
+
|
|
21
|
+
export { liveTags } from './live-data-tags.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
RANGE_LIMIT,
|
|
25
|
+
RECEIPT_LIMIT,
|
|
26
|
+
unionRanges,
|
|
27
|
+
rangeCount,
|
|
28
|
+
hasNumber,
|
|
29
|
+
contains,
|
|
30
|
+
identitySet,
|
|
31
|
+
frameSet,
|
|
32
|
+
descendants,
|
|
33
|
+
parseBy,
|
|
34
|
+
formatBy,
|
|
35
|
+
receiptHas,
|
|
36
|
+
receiptBytes,
|
|
37
|
+
replaceReceipt,
|
|
38
|
+
retireReceipt,
|
|
39
|
+
saveReceipt,
|
|
40
|
+
postReceipt,
|
|
41
|
+
} from './live-provenance.js';
|
|
42
|
+
|
|
43
|
+
export { isHybridPageValue, isYPageValue, inspectPage, pageBaseline } from './page-admission.js';
|
|
44
|
+
|
|
45
|
+
export { splitPage, composePage, pageRev } from './page-state.js';
|
|
46
|
+
|
|
47
|
+
export { closestBase, rebasePage, stampPageRev } from './page-rebase.js';
|
|
48
|
+
|
|
49
|
+
export { PageConversionError, parsePageConversion, checkPageConversion } from './page-conversion.js';
|
|
50
|
+
|
|
51
|
+
export { checkPageInitial, checkPageWriteAdmission } from './page-write-admission.js';
|
|
52
|
+
|
|
53
|
+
export { PageFormatError, checkPageWriteFormat, pageLineageLost } from './page-write-guard.js';
|
|
54
|
+
|
|
55
|
+
export { makePageTransition, pageTransitionState } from './live-page-history.js';
|
|
56
|
+
|
|
57
|
+
export { documentEtag, ifMatchSatisfied } from './spec-wire.js';
|
|
58
|
+
|
|
59
|
+
export { LIVE_LIMITS, REFUSED_MS, createRooms, roomIsDirty, PersistentReservationError } from './live-data-rooms.js';
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// The operation model behind clay.live stores: a verbatim copy of ClayJS's
|
|
2
|
+
// src/live/ops.js. Keep the two identical; tests/fixtures/live-ops.json pins both.
|
|
3
|
+
// Every client and the server apply the same ops to the same value and get
|
|
4
|
+
// byte-identical JSON, object key order included, so nothing here may depend
|
|
5
|
+
// on timing or on where a value came from.
|
|
6
|
+
|
|
7
|
+
const INDEX = /^(0|[1-9]\d*)$/;
|
|
8
|
+
const OPS = new Set(["set", "del", "add"]);
|
|
9
|
+
|
|
10
|
+
const isContainer = (v) => v !== null && typeof v === "object";
|
|
11
|
+
const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
12
|
+
|
|
13
|
+
function setOwn(obj, key, value) {
|
|
14
|
+
Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// "players.alice.x" or ["players", "alice", "x"] -> segments. In the dotted form a
|
|
18
|
+
// canonical non-negative integer becomes an index; a key containing a dot needs
|
|
19
|
+
// the array form.
|
|
20
|
+
export function parsePath(path) {
|
|
21
|
+
if (Array.isArray(path)) {
|
|
22
|
+
for (const seg of path) checkSegment(seg);
|
|
23
|
+
return path.slice();
|
|
24
|
+
}
|
|
25
|
+
if (typeof path !== "string") throw new TypeError("clay.live: a path is a dotted string or an array of keys");
|
|
26
|
+
if (path === "") return [];
|
|
27
|
+
return path.split(".").map((s) => (INDEX.test(s) ? Number(s) : s));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkSegment(seg) {
|
|
31
|
+
if (typeof seg === "string") return;
|
|
32
|
+
if (Number.isInteger(seg) && seg >= 0) return;
|
|
33
|
+
throw new TypeError(`clay.live: path segment ${String(seg)} is not a string or a non-negative integer`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A deep copy that refuses anything JSON cannot hold, so a stored value can
|
|
37
|
+
// never be mutated through an outside reference and always serializes.
|
|
38
|
+
export function cloneJson(value, seen = new Set()) {
|
|
39
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
40
|
+
if (typeof value === "number") {
|
|
41
|
+
if (!Number.isFinite(value)) throw new TypeError("clay.live: numbers must be finite");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== "object") throw new TypeError(`clay.live: a ${typeof value} is not JSON`);
|
|
45
|
+
if (seen.has(value)) throw new TypeError("clay.live: the value contains a cycle");
|
|
46
|
+
seen.add(value);
|
|
47
|
+
let out;
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
out = [];
|
|
50
|
+
for (let i = 0; i < value.length; i++) {
|
|
51
|
+
if (!(i in value)) throw new TypeError("clay.live: sparse arrays are not JSON");
|
|
52
|
+
out.push(cloneJson(value[i], seen));
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const proto = Object.getPrototypeOf(value);
|
|
56
|
+
if (proto !== Object.prototype && proto !== null) throw new TypeError("clay.live: only plain objects and arrays are JSON");
|
|
57
|
+
out = {};
|
|
58
|
+
for (const key of Object.keys(value)) setOwn(out, key, cloneJson(value[key], seen));
|
|
59
|
+
}
|
|
60
|
+
seen.delete(value);
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Shape check, before anything is touched. Returns a normalized op with its
|
|
65
|
+
// value already cloned.
|
|
66
|
+
export function validateOp(op) {
|
|
67
|
+
if (!isContainer(op) || !OPS.has(op.op)) throw new TypeError("clay.live: an op is { op: \"set\" | \"del\" | \"add\", path, ... }");
|
|
68
|
+
const path = parsePath(op.path);
|
|
69
|
+
if (op.op === "set") {
|
|
70
|
+
if (op.value === undefined) throw new TypeError("clay.live: set needs a value; use delete to remove");
|
|
71
|
+
const value = cloneJson(op.value);
|
|
72
|
+
if (path.length === 0 && !isContainer(value)) throw new TypeError("clay.live: the whole value must be an object or an array");
|
|
73
|
+
return { op: "set", path, value };
|
|
74
|
+
}
|
|
75
|
+
if (path.length === 0) throw new TypeError(`clay.live: ${op.op} needs a path`);
|
|
76
|
+
if (op.op === "add") {
|
|
77
|
+
if (typeof op.n !== "number" || !Number.isFinite(op.n)) throw new TypeError("clay.live: add needs a finite number");
|
|
78
|
+
return { op: "add", path, n: op.n };
|
|
79
|
+
}
|
|
80
|
+
return { op: "del", path };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function indexOf(arr, seg) {
|
|
84
|
+
if (typeof seg === "number") return seg;
|
|
85
|
+
if (INDEX.test(seg)) return Number(seg);
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function read(container, seg) {
|
|
90
|
+
if (Array.isArray(container)) {
|
|
91
|
+
const i = indexOf(container, seg);
|
|
92
|
+
return i >= 0 && i < container.length ? container[i] : undefined;
|
|
93
|
+
}
|
|
94
|
+
const key = String(seg);
|
|
95
|
+
return has(container, key) ? container[key] : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Throws, without mutating anything, when op cannot apply to root: a string
|
|
99
|
+
// key on a list, or a list index past the end. A missing or non-container
|
|
100
|
+
// step along the way is not an error: applyOp replaces it with a container.
|
|
101
|
+
export function checkOp(root, op) {
|
|
102
|
+
if (op.path.length === 0) {
|
|
103
|
+
if (op.op === "set" && (!isContainer(op.value) || Array.isArray(op.value) !== Array.isArray(root))) {
|
|
104
|
+
throw new TypeError(`clay.live: the whole value must stay ${Array.isArray(root) ? "a list" : "an object"}`);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let node = root;
|
|
109
|
+
for (let i = 0; i < op.path.length; i++) {
|
|
110
|
+
const seg = op.path[i];
|
|
111
|
+
const last = i === op.path.length - 1;
|
|
112
|
+
if (!isContainer(node)) {
|
|
113
|
+
// The rest of the path will be created, and a new list starts empty: an
|
|
114
|
+
// index other than 0 would leave holes, which are not JSON.
|
|
115
|
+
if (op.op === "del") return;
|
|
116
|
+
for (let j = i; j < op.path.length; j++) {
|
|
117
|
+
if (typeof op.path[j] === "number" && op.path[j] !== 0) {
|
|
118
|
+
throw new RangeError(`clay.live: index ${op.path[j]} would leave holes in a new list at ${op.path.slice(0, j).join(".") || "the root"}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(node)) {
|
|
124
|
+
const idx = indexOf(node, seg);
|
|
125
|
+
if (idx < 0) throw new TypeError(`clay.live: "${seg}" is not an index of the list at ${op.path.slice(0, i).join(".") || "the root"}`);
|
|
126
|
+
const limit = last && op.op !== "del" ? node.length : node.length - 1;
|
|
127
|
+
if (idx > limit && !(last && op.op === "del")) throw new RangeError(`clay.live: index ${idx} is past the end of the list at ${op.path.slice(0, i).join(".") || "the root"}`);
|
|
128
|
+
}
|
|
129
|
+
if (last) return;
|
|
130
|
+
node = read(node, seg);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function put(container, seg, value) {
|
|
135
|
+
if (Array.isArray(container)) container[indexOf(container, seg)] = value;
|
|
136
|
+
else setOwn(container, String(seg), value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Applies one checked op. Mutates root and returns it, or returns the new root
|
|
140
|
+
// for a whole-value set.
|
|
141
|
+
export function applyOp(root, op) {
|
|
142
|
+
const { path } = op;
|
|
143
|
+
if (path.length === 0) return cloneJson(op.value);
|
|
144
|
+
let parent = root;
|
|
145
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
146
|
+
let next = read(parent, path[i]);
|
|
147
|
+
if (!isContainer(next)) {
|
|
148
|
+
if (op.op === "del") return root;
|
|
149
|
+
next = typeof path[i + 1] === "number" ? [] : {};
|
|
150
|
+
put(parent, path[i], next);
|
|
151
|
+
}
|
|
152
|
+
parent = next;
|
|
153
|
+
}
|
|
154
|
+
const last = path[path.length - 1];
|
|
155
|
+
if (op.op === "set") {
|
|
156
|
+
put(parent, last, cloneJson(op.value));
|
|
157
|
+
} else if (op.op === "add") {
|
|
158
|
+
const cur = read(parent, last);
|
|
159
|
+
put(parent, last, (typeof cur === "number" && Number.isFinite(cur) ? cur : 0) + op.n);
|
|
160
|
+
} else if (Array.isArray(parent)) {
|
|
161
|
+
const idx = indexOf(parent, last);
|
|
162
|
+
if (idx >= 0 && idx < parent.length) parent.splice(idx, 1);
|
|
163
|
+
} else {
|
|
164
|
+
delete parent[String(last)];
|
|
165
|
+
}
|
|
166
|
+
return root;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Applies a list of ops in order; checks each one against the value as it
|
|
170
|
+
// stands at that point. Callers that need atomicity apply to a clone.
|
|
171
|
+
export function applyOps(root, ops) {
|
|
172
|
+
let value = root;
|
|
173
|
+
for (const op of ops) {
|
|
174
|
+
checkOp(value, op);
|
|
175
|
+
value = applyOp(value, op);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// applyOp, keeping what it changed so a caller can size the change or take it
|
|
181
|
+
// back. null when the op changes nothing. Otherwise root is the value to go on
|
|
182
|
+
// with (a new one only for a whole-value set, where at is null and old is the
|
|
183
|
+
// previous root), and at[key] is the one slot the op changed, at path. kind
|
|
184
|
+
// says how: "put" replaced old, "add" made the slot (a new key, or an item
|
|
185
|
+
// appended to a list of size items), "remove" took old out. For an object, keys
|
|
186
|
+
// lists its keys before a remove, and empty says it had none before an add.
|
|
187
|
+
// Checks the op first, so a throw changes nothing.
|
|
188
|
+
export function applyTracked(root, op) {
|
|
189
|
+
checkOp(root, op);
|
|
190
|
+
const { path } = op;
|
|
191
|
+
if (path.length === 0) {
|
|
192
|
+
return { root: cloneJson(op.value), at: null, key: null, kind: "put", old: root, path: [], size: 0, keys: null, empty: false };
|
|
193
|
+
}
|
|
194
|
+
let at = root;
|
|
195
|
+
let depth = path.length - 1;
|
|
196
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
197
|
+
const next = read(at, path[i]);
|
|
198
|
+
if (!isContainer(next)) {
|
|
199
|
+
if (op.op === "del") return null;
|
|
200
|
+
depth = i;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
at = next;
|
|
204
|
+
}
|
|
205
|
+
const list = Array.isArray(at);
|
|
206
|
+
const key = list ? indexOf(at, path[depth]) : String(path[depth]);
|
|
207
|
+
const size = list ? at.length : 0;
|
|
208
|
+
const had = list ? key < size : has(at, key);
|
|
209
|
+
if (op.op === "del" && !had) return null;
|
|
210
|
+
const kind = op.op === "del" ? "remove" : had ? "put" : "add";
|
|
211
|
+
const rec = { root, at, key, kind, old: had ? at[key] : undefined, path: path.slice(0, depth + 1), size, keys: null, empty: false };
|
|
212
|
+
if (!list && kind === "remove") rec.keys = Object.keys(at);
|
|
213
|
+
if (!list && kind === "add") rec.empty = isEmpty(at);
|
|
214
|
+
applyOp(root, op);
|
|
215
|
+
return rec;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isEmpty(obj) {
|
|
219
|
+
for (const key in obj) if (has(obj, key)) return false;
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Takes back one applyTracked change; a frame's records go back newest first.
|
|
224
|
+
// Returns the root as it was before that op.
|
|
225
|
+
export function undoTracked(rec) {
|
|
226
|
+
const { at, key, kind, old } = rec;
|
|
227
|
+
if (at === null) return old;
|
|
228
|
+
if (Array.isArray(at)) {
|
|
229
|
+
if (kind === "put") at[key] = old;
|
|
230
|
+
else if (kind === "add") at.length = rec.size;
|
|
231
|
+
else at.splice(key, 0, old);
|
|
232
|
+
} else if (kind === "put") setOwn(at, key, old);
|
|
233
|
+
else if (kind === "add") delete at[key];
|
|
234
|
+
else if (rec.keys[rec.keys.length - 1] === key) setOwn(at, key, old);
|
|
235
|
+
else {
|
|
236
|
+
// Back in its place: every key after it is put back behind it.
|
|
237
|
+
const values = rec.keys.map((k) => (k === key ? old : at[k]));
|
|
238
|
+
for (const k of rec.keys) if (k !== key) delete at[k];
|
|
239
|
+
rec.keys.forEach((k, i) => setOwn(at, k, values[i]));
|
|
240
|
+
}
|
|
241
|
+
return rec.root;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// What one applyTracked change did to the length of serializeLive(root), with
|
|
245
|
+
// size(v) the length of serializeLive(v) in the caller's unit and total the
|
|
246
|
+
// root's length before. Call it before the next op touches the slot.
|
|
247
|
+
export function sizeChange(rec, size, total) {
|
|
248
|
+
const { at, key, kind, old } = rec;
|
|
249
|
+
if (at === null) return size(rec.root) - total;
|
|
250
|
+
const list = Array.isArray(at);
|
|
251
|
+
const slot = list ? 0 : size(key) + 1;
|
|
252
|
+
if (kind === "put") return size(at[key]) - size(old);
|
|
253
|
+
if (kind === "add") return slot + size(at[key]) + ((list ? rec.size > 0 : !rec.empty) ? 1 : 0);
|
|
254
|
+
return -(slot + size(old)) - ((list ? rec.size : rec.keys.length) > 1 ? 1 : 0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const samePath = (a, b) => a.length === b.length && a.every((s, i) => String(s) === String(b[i]));
|
|
258
|
+
|
|
259
|
+
// Merges neighbouring ops on the same path, without changing the result:
|
|
260
|
+
// set,set -> the later set; add,add -> one add of the sum; set,add -> one set of
|
|
261
|
+
// the sum; set,del and add,del -> the del, but only on a one-segment path (a
|
|
262
|
+
// deeper set may have created the parents the del leaves behind). A del is never
|
|
263
|
+
// merged with what follows it (on a list it shifts the indexes after it).
|
|
264
|
+
export function coalesce(ops) {
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const op of ops) {
|
|
267
|
+
const prev = out[out.length - 1];
|
|
268
|
+
if (!prev || prev.op === "del" || !samePath(prev.path, op.path)) {
|
|
269
|
+
out.push(op);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (op.op === "del" && op.path.length > 1) {
|
|
273
|
+
out.push(op);
|
|
274
|
+
} else if (op.op === "set" || op.op === "del") {
|
|
275
|
+
out[out.length - 1] = op;
|
|
276
|
+
} else if (prev.op === "add") {
|
|
277
|
+
out[out.length - 1] = { op: "add", path: prev.path, n: prev.n + op.n };
|
|
278
|
+
} else {
|
|
279
|
+
const base = typeof prev.value === "number" ? prev.value : 0;
|
|
280
|
+
out[out.length - 1] = { op: "set", path: prev.path, value: base + op.n };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// True when one path is a prefix of the other: an op on one can change what
|
|
287
|
+
// the other reads.
|
|
288
|
+
export function overlaps(a, b) {
|
|
289
|
+
const n = Math.min(a.length, b.length);
|
|
290
|
+
for (let i = 0; i < n; i++) if (String(a[i]) !== String(b[i])) return false;
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Makes target equal next in place, keeping the identity of every nested
|
|
295
|
+
// object or list that still sits at the same path with the same type, so a
|
|
296
|
+
// reference handed out earlier stays live. Object keys end up in next's order.
|
|
297
|
+
// next's own nested values are moved in, not copied: pass a fresh value.
|
|
298
|
+
export function assignDeep(target, next) {
|
|
299
|
+
if (Array.isArray(target)) {
|
|
300
|
+
for (let i = 0; i < next.length; i++) target[i] = reuse(i < target.length ? target[i] : undefined, next[i]);
|
|
301
|
+
target.length = next.length;
|
|
302
|
+
return target;
|
|
303
|
+
}
|
|
304
|
+
const keys = Object.keys(next);
|
|
305
|
+
const values = keys.map((k) => reuse(has(target, k) ? target[k] : undefined, next[k]));
|
|
306
|
+
for (const k of Object.keys(target)) delete target[k];
|
|
307
|
+
keys.forEach((k, i) => setOwn(target, k, values[i]));
|
|
308
|
+
return target;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function reuse(cur, next) {
|
|
312
|
+
if (isContainer(cur) && isContainer(next) && Array.isArray(cur) === Array.isArray(next)) return assignDeep(cur, next);
|
|
313
|
+
return next;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// The text a live store writes into its script tag. `<` is escaped so a value
|
|
317
|
+
// holding "</script>" can never end the element.
|
|
318
|
+
export function serializeLive(value) {
|
|
319
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// A live tag's lineage, `live-at="<epoch>:<seq>:<hash>"`: the room position its
|
|
323
|
+
// value was written at, and a hash of the exact text, so a room can tell its own
|
|
324
|
+
// unedited value from an edit of it.
|
|
325
|
+
const STAMP = /^([A-Za-z0-9-]{1,64}):(0|[1-9]\d*):([0-9a-f]{8})$/;
|
|
326
|
+
|
|
327
|
+
// FNV-1a over UTF-16 code units, as 8 hex digits. Both copies must agree.
|
|
328
|
+
export function textHash(text) {
|
|
329
|
+
let h = 0x811c9dc5;
|
|
330
|
+
for (let i = 0; i < text.length; i++) {
|
|
331
|
+
h ^= text.charCodeAt(i);
|
|
332
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
333
|
+
}
|
|
334
|
+
return h.toString(16).padStart(8, "0");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function stampOf(epoch, seq, text) {
|
|
338
|
+
return `${epoch}:${seq}:${textHash(text)}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function parseStamp(value) {
|
|
342
|
+
const m = typeof value === "string" ? STAMP.exec(value) : null;
|
|
343
|
+
return m ? { epoch: m[1], seq: Number(m[2]), hash: m[3] } : null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// The leaf-level ops that turn before into after: set for a changed or added
|
|
347
|
+
// leaf, del for a removed key, a whole-value set where the container kind or a
|
|
348
|
+
// list's length changes. Every op is idempotent, so replaying the result over a
|
|
349
|
+
// value that already holds `after` at those paths changes nothing.
|
|
350
|
+
export function diffOps(before, after, path = []) {
|
|
351
|
+
if (!isContainer(before) || !isContainer(after) || Array.isArray(before) !== Array.isArray(after)) {
|
|
352
|
+
return [{ op: "set", path, value: cloneJson(after) }];
|
|
353
|
+
}
|
|
354
|
+
if (Array.isArray(before)) {
|
|
355
|
+
if (before.length !== after.length) return [{ op: "set", path, value: cloneJson(after) }];
|
|
356
|
+
const out = [];
|
|
357
|
+
for (let i = 0; i < after.length; i++) {
|
|
358
|
+
if (serializeLive(before[i]) !== serializeLive(after[i])) out.push(...diffOps(before[i], after[i], [...path, i]));
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const key of Object.keys(before)) if (!has(after, key)) out.push({ op: "del", path: [...path, key] });
|
|
364
|
+
for (const key of Object.keys(after)) {
|
|
365
|
+
if (!has(before, key)) out.push({ op: "set", path: [...path, key], value: cloneJson(after[key]) });
|
|
366
|
+
else if (serializeLive(before[key]) !== serializeLive(after[key])) out.push(...diffOps(before[key], after[key], [...path, key]));
|
|
367
|
+
}
|
|
368
|
+
return out;
|
|
369
|
+
}
|