@panphora/clayjs 1.16.0 → 1.17.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/dist/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +7917 -5621
- package/dist/m/attrs/refetch-on-save-ZUE7PSCI.js +1 -0
- package/dist/m/attrs/save-freeze-MYNVXREN.js +1 -0
- package/dist/m/c-2YYDMLNZ.js +1 -0
- package/dist/m/c-336SGU5W.js +1 -0
- package/dist/m/c-3H6BZDNJ.js +6 -0
- package/dist/m/{c-LR4CGEPT.js → c-3RUU4TPX.js} +1 -1
- package/dist/m/c-3XIRD4NU.js +1 -0
- package/dist/m/c-4DFEJ2PO.js +3 -0
- package/dist/m/c-56QRZTZH.js +1 -0
- package/dist/m/c-5J4E7O4Y.js +4 -0
- package/dist/m/c-5LKDWQTL.js +19 -0
- package/dist/m/c-5QWQ7A5L.js +1 -0
- package/dist/m/c-5RJFTLSF.js +1 -0
- package/dist/m/c-6AZOGAPE.js +1 -0
- package/dist/m/{c-Y6USGTS5.js → c-6CRUUXOX.js} +5 -5
- package/dist/m/{c-54RCDCBU.js → c-6KEVEQ7L.js} +1 -1
- package/dist/m/{c-LTGMXXKO.js → c-6NC5I2TV.js} +1 -1
- package/dist/m/{c-YDH7Q3NY.js → c-74MGYWZF.js} +1 -1
- package/dist/m/{c-Z36J63PP.js → c-7OLLJPWP.js} +1 -1
- package/dist/m/c-A3E3NIMC.js +1 -0
- package/dist/m/{c-5R4PTRG2.js → c-AMSNPOCX.js} +1 -1
- package/dist/m/c-B3JM5ZXC.js +1 -0
- package/dist/m/c-BPF2NSPV.js +1 -0
- package/dist/m/c-BQ6LOBEE.js +1 -0
- package/dist/m/c-CJ3A7ZKE.js +1 -0
- package/dist/m/c-CQHPGKX2.js +1 -0
- package/dist/m/c-DCXOXBF3.js +1 -0
- package/dist/m/c-DFS4XZJV.js +2 -0
- package/dist/m/{c-F3CKJTJS.js → c-DNENWY4O.js} +1 -1
- package/dist/m/c-E4LIR5X5.js +1 -0
- package/dist/m/c-EVCQ3BKA.js +1 -0
- package/dist/m/{c-5Y6TNX5W.js → c-EZU56NQV.js} +2 -2
- package/dist/m/c-FDNHHZY5.js +1 -0
- package/dist/m/{c-SWRJFCNN.js → c-FRLHSY7E.js} +1 -1
- package/dist/m/c-FROJUZMV.js +1 -0
- package/dist/m/c-G2JPKCTP.js +1 -0
- package/dist/m/c-GB6TU7WV.js +1 -0
- package/dist/m/{c-GB3JTV6E.js → c-GFJJEBTU.js} +1 -1
- package/dist/m/{c-J7CA54IP.js → c-GSFIGKOP.js} +1 -1
- package/dist/m/{c-OYTFP5QN.js → c-GY422N3Q.js} +1 -1
- package/dist/m/c-HAWFNBYG.js +1 -0
- package/dist/m/{c-33HUYEDI.js → c-HDDEEL77.js} +1 -1
- package/dist/m/c-HFKUVACH.js +1 -0
- package/dist/m/c-HHDFCNHV.js +1 -0
- package/dist/m/c-I2ZATQCD.js +2 -0
- package/dist/m/c-IEM4NTCS.js +1 -0
- package/dist/m/c-IW6GGOU2.js +1 -0
- package/dist/m/{c-SGCCC4FW.js → c-JDCX26UP.js} +2 -2
- package/dist/m/c-JRKY44DK.js +1 -0
- package/dist/m/c-JU3NK25H.js +1 -0
- package/dist/m/{c-VFX45IZQ.js → c-KAU6E43D.js} +1 -1
- package/dist/m/c-KBS3UV6B.js +1 -0
- package/dist/m/c-KGVGROZT.js +1 -0
- package/dist/m/{c-YG2NJYS6.js → c-KQ6TYJAP.js} +2 -2
- package/dist/m/{c-2JL6E75N.js → c-KU3ZFADW.js} +1 -1
- package/dist/m/c-LWLBHLVC.js +3 -0
- package/dist/m/c-M2L47S2X.js +7 -0
- package/dist/m/c-MFP2ZWD4.js +1 -0
- package/dist/m/{c-4YSCRICO.js → c-MKSQ4Y57.js} +1 -1
- package/dist/m/{c-2M73252O.js → c-NUFFJVVJ.js} +1 -1
- package/dist/m/c-NUHSPT5P.js +1 -0
- package/dist/m/c-O72HVYSA.js +1 -0
- package/dist/m/c-OW5QIGG3.js +1 -0
- package/dist/m/c-PGRCK73F.js +1 -0
- package/dist/m/{c-4DRMS4QG.js → c-PS5ARVWR.js} +1 -1
- package/dist/m/c-Q622NS2Y.js +1 -0
- package/dist/m/c-Q7NQD65V.js +1 -0
- package/dist/m/c-QH63LQOS.js +1 -0
- package/dist/m/{c-TQZ2JYHD.js → c-QICP2YR5.js} +1 -1
- package/dist/m/{c-7VGZ7RU5.js → c-QJ4OM3G6.js} +1 -1
- package/dist/m/{c-MMUIUUZG.js → c-R3L7ZJGB.js} +1 -1
- package/dist/m/c-R7IWVINQ.js +1 -0
- package/dist/m/{c-HVYXIS72.js → c-RSMOODDU.js} +1 -1
- package/dist/m/{c-KKWLDOPM.js → c-RYUXUUBX.js} +1 -1
- package/dist/m/c-S4ZEH46B.js +9 -0
- package/dist/m/c-SDAXDM2D.js +1 -0
- package/dist/m/c-SMBZG5X4.js +1 -0
- package/dist/m/{c-UXBMRDZB.js → c-SMGU7RT3.js} +1 -1
- package/dist/m/c-SPIORRYS.js +1 -0
- package/dist/m/c-SWLRT66R.js +1 -0
- package/dist/m/c-SZGYW5HT.js +1 -0
- package/dist/m/c-TCJTVAFN.js +1 -0
- package/dist/m/{c-HRDCD3WN.js → c-TJ6WOJL3.js} +1 -1
- package/dist/m/c-TYLXX4NF.js +21 -0
- package/dist/m/c-U62OB4BT.js +1 -0
- package/dist/m/{c-NMJRXKPC.js → c-U6YWUAR3.js} +1 -1
- package/dist/m/c-UDCSU5G5.js +2 -0
- package/dist/m/{c-NZMNXVF6.js → c-UDUUJPFZ.js} +1 -1
- package/dist/m/c-VE6ZMYMC.js +1 -0
- package/dist/m/c-VG5XIN4T.js +1 -0
- package/dist/m/c-VHM5XQHK.js +1 -0
- package/dist/m/c-VI5RMSVY.js +1 -0
- package/dist/m/c-VRZN56XY.js +1 -0
- package/dist/m/c-W5MCHEGA.js +1 -0
- package/dist/m/c-WDC5GVKU.js +1 -0
- package/dist/m/c-WWXIDH4O.js +28 -0
- package/dist/m/{c-QIX5BJYN.js → c-XCNTK76U.js} +1 -1
- package/dist/m/c-XMS6DPBR.js +1 -0
- package/dist/m/c-Y5HBBIQ4.js +1 -0
- package/dist/m/{c-5M4YEKCX.js → c-ZVPVXTJF.js} +1 -1
- package/dist/m/c-ZVRKXKM7.js +1 -0
- package/dist/m/{c-HT33EQRN.js → c-ZWIOJGMT.js} +1 -1
- package/dist/m/core/admin-attrs-QHOKPJ7W.js +1 -0
- package/dist/m/core/autosave-HRYGUWV6.js +1 -0
- package/dist/m/core/conflict-notice-6MROJN2L.js +1 -0
- package/dist/m/core/host-permission-FNXVEFNE.js +1 -0
- package/dist/m/core/is-edit-mode-KTWA3O5N.js +1 -0
- package/dist/m/core/{persist-HKHA66IY.js → persist-JFYRHMRJ.js} +1 -1
- package/dist/m/core/recovery-FLTCC2OC.js +1 -0
- package/dist/m/core/save-HQZ4S667.js +1 -0
- package/dist/m/core/save-core-KGQWFUNH.js +1 -0
- package/dist/m/core/snapshot-EGQZNT45.js +1 -0
- package/dist/m/core/unsaved-warning-IDJLD66H.js +1 -0
- package/dist/m/events/index-RN2JNGBV.js +1 -0
- package/dist/m/internals/index-PVXGPUDA.js +1 -0
- package/dist/m/lib/cache-bust-MUMFXZK6.js +1 -0
- package/dist/m/loader-S4I7GKXR.js +1 -0
- package/dist/m/manifest-4C419D67.js +1 -0
- package/dist/m/manifest-edit-B077F288.js +1 -0
- package/dist/m/manifest.json +1555 -1058
- package/dist/m/options/options-EJMS3K24.js +1 -0
- package/dist/m/page/{editor-XUTB2HB2.js → editor-NNJA4OKD.js} +21 -8
- package/dist/m/plugins/{ai-edit-ZQ4CGWIP.js → ai-edit-QN4PMMNL.js} +1 -1
- package/dist/m/plugins/demo-RM5IR6AO.js +1 -0
- package/dist/m/plugins/indicator-URDBBOQO.js +1 -0
- package/dist/m/plugins/live-HFWD4OIH.js +1 -0
- package/dist/m/plugins/{menu-HCKO3DSP.js → menu-3HIEQSI6.js} +2 -2
- package/dist/m/plugins/page-JOKB6XPR.js +2 -0
- package/dist/m/plugins/page-view-NXOJHNW6.js +1 -0
- package/dist/m/plugins/people-SGPJKANK.js +1 -0
- package/dist/m/plugins/{richclay-RC3L2F6X.js → richclay-NRQDSWX6.js} +2 -2
- package/dist/m/plugins/sortable-GWT4PWDV.js +1 -0
- package/dist/m/plugins/source-4ZRSAIYM.js +1 -0
- package/dist/m/plugins/undo-VDA5P7FH.js +1 -0
- package/dist/m/plugins/{upgrade-ZNYODP5Y.js → upgrade-W7FG5VFG.js} +1 -1
- package/dist/m/plugins/upload-WLYKK6TL.js +1 -0
- package/dist/m/plugins/visitor-drafts-LIDY7NH6.js +21 -0
- package/dist/m/plugins/visitor-drafts-owner-ZXCKBZE7.js +1 -0
- package/dist/m/plugins/wire-4N6GYAF6.js +1 -0
- package/dist/m/{reader-core-ILWLXVMP.js → reader-core-DXYCURFF.js} +2 -2
- package/dist/m/sync/live-sync-J7VMYTKS.js +1 -0
- package/dist/m/ui/{index-EVHKMKBL.js → index-3JLVI27O.js} +1 -1
- package/dist/offline-manifest.json +857 -623
- package/dist/page/themes/light-1.css +25 -1
- package/dist/server/core.cjs +115 -24
- package/package.json +1 -1
- package/src/core/boot-phase.js +2 -1
- package/src/core/conflict-download.js +3 -29
- package/src/core/download.js +32 -0
- package/src/core/host-meta.js +18 -0
- package/src/core/save-core.js +8 -4
- package/src/core/save.js +4 -4
- package/src/core/snapshot.js +39 -1
- package/src/lib/autosave-scheduler.js +3 -4
- package/src/page/blocks.js +34 -1
- package/src/page/board-views.js +2 -16
- package/src/page/chart-tables.js +11 -0
- package/src/page/chart.js +6 -10
- package/src/page/checkpoint-proof.js +289 -0
- package/src/page/chrome.js +24 -6
- package/src/page/document-title-dom.js +12 -0
- package/src/page/document-title-runtime.js +15 -1
- package/src/page/document-title.js +4 -1
- package/src/page/editor.js +77 -12
- package/src/page/emoji-data.js +162 -0
- package/src/page/features/board.js +12 -3
- package/src/page/features/chart.js +11 -0
- package/src/page/features/dates.js +180 -0
- package/src/page/features/emoji.js +187 -0
- package/src/page/features/mentions.js +188 -0
- package/src/page/features/registry.js +6 -1
- package/src/page/features/table.js +2 -2
- package/src/page/format-command.js +9 -0
- package/src/page/format-host.js +54 -0
- package/src/page/format-native-commands.js +142 -0
- package/src/page/format-native.js +42 -0
- package/src/page/inline.js +32 -8
- package/src/page/items.js +15 -3
- package/src/page/markdown.js +26 -2
- package/src/page/mention-picker.js +99 -0
- package/src/page/merge.js +1 -1
- package/src/page/projection.js +58 -15
- package/src/page/save-projection.js +39 -0
- package/src/page/save-states.js +1 -1
- package/src/page/schema.js +33 -0
- package/src/page/themes/content.css +25 -1
- package/src/page/themes/light-1.css +25 -1
- package/src/page/view-hooks.js +32 -0
- package/src/page/ybind/binding.js +31 -4
- package/src/page/ybind/marks.js +2 -1
- package/src/page/ybind/provider.js +13 -2
- package/src/page/ybind/recovery-replay.js +362 -4
- package/src/plugins/live.js +37 -1
- package/src/plugins/page.js +117 -3
- package/src/plugins/visitor-drafts/intro.js +2 -1
- package/src/server/checkpoint/index.js +2 -2
- package/src/sync/saved-stream.js +17 -8
- package/dist/m/attrs/refetch-on-save-NWPPXOCZ.js +0 -1
- package/dist/m/attrs/save-freeze-FUP4Q4X3.js +0 -1
- package/dist/m/c-35NBGMZB.js +0 -1
- package/dist/m/c-3IQRECYS.js +0 -1
- package/dist/m/c-4B6H4G5V.js +0 -1
- package/dist/m/c-4FFHZM32.js +0 -1
- package/dist/m/c-66O32MV3.js +0 -28
- package/dist/m/c-7O6AFR65.js +0 -1
- package/dist/m/c-ANQTJ3B5.js +0 -2
- package/dist/m/c-AYVOREBE.js +0 -1
- package/dist/m/c-B2ZXPOS3.js +0 -3
- package/dist/m/c-BMIZKMPV.js +0 -1
- package/dist/m/c-CJZSLMVQ.js +0 -1
- package/dist/m/c-DBE6ORSI.js +0 -1
- package/dist/m/c-EDHUR4MX.js +0 -1
- package/dist/m/c-EY6UOM54.js +0 -1
- package/dist/m/c-F7Y6FLPY.js +0 -1
- package/dist/m/c-FA4DZVTC.js +0 -1
- package/dist/m/c-G2BUZITR.js +0 -1
- package/dist/m/c-GBGWXTX4.js +0 -1
- package/dist/m/c-GHBCCVG4.js +0 -1
- package/dist/m/c-IB52R237.js +0 -1
- package/dist/m/c-IKWNE4R4.js +0 -1
- package/dist/m/c-IU3IYEU6.js +0 -1
- package/dist/m/c-IZ2EBRAJ.js +0 -1
- package/dist/m/c-KR4UW4KW.js +0 -1
- package/dist/m/c-KY3VJGUA.js +0 -8
- package/dist/m/c-LXENEBV7.js +0 -1
- package/dist/m/c-PPOSCAQD.js +0 -1
- package/dist/m/c-QQHEX6HA.js +0 -1
- package/dist/m/c-R7RMANJI.js +0 -9
- package/dist/m/c-RI4NT75Y.js +0 -1
- package/dist/m/c-RUJKSUQW.js +0 -1
- package/dist/m/c-T4VO4OCB.js +0 -1
- package/dist/m/c-TR6RMDQ7.js +0 -3
- package/dist/m/c-UKHTLWUW.js +0 -1
- package/dist/m/c-UNN7LWCT.js +0 -1
- package/dist/m/c-V7PZZRG7.js +0 -1
- package/dist/m/c-VL52ADDR.js +0 -1
- package/dist/m/c-WWDBYXLK.js +0 -1
- package/dist/m/c-XNNW2CWK.js +0 -1
- package/dist/m/c-Z4VRC7GU.js +0 -7
- package/dist/m/c-Z6K52OLP.js +0 -19
- package/dist/m/c-ZDY2MDAA.js +0 -21
- package/dist/m/core/admin-attrs-VGNLAUDK.js +0 -1
- package/dist/m/core/autosave-E4PEFPZ6.js +0 -1
- package/dist/m/core/conflict-notice-VQ6Y64PH.js +0 -1
- package/dist/m/core/host-permission-XN3K2IW3.js +0 -1
- package/dist/m/core/is-edit-mode-JXHDVGUL.js +0 -1
- package/dist/m/core/recovery-TBBSICE2.js +0 -1
- package/dist/m/core/save-WUCHTQBE.js +0 -1
- package/dist/m/core/save-core-FJVHT5M3.js +0 -1
- package/dist/m/core/snapshot-F3SWDZMM.js +0 -1
- package/dist/m/core/unsaved-warning-STKBHE6G.js +0 -1
- package/dist/m/events/index-23DPTUT4.js +0 -1
- package/dist/m/internals/index-WOVAP3RU.js +0 -1
- package/dist/m/lib/cache-bust-CGWT6ZJI.js +0 -1
- package/dist/m/loader-HXX6YNH5.js +0 -1
- package/dist/m/manifest-F22DEDAC.js +0 -1
- package/dist/m/manifest-edit-C3C7376B.js +0 -1
- package/dist/m/options/options-2LH76JQ3.js +0 -1
- package/dist/m/plugins/demo-ZIBFAA3R.js +0 -1
- package/dist/m/plugins/indicator-PV4YW2ID.js +0 -1
- package/dist/m/plugins/live-NUTTXSBS.js +0 -1
- package/dist/m/plugins/page-KSUH7G6J.js +0 -2
- package/dist/m/plugins/page-view-W73OW7WI.js +0 -1
- package/dist/m/plugins/people-AJJ32YCG.js +0 -1
- package/dist/m/plugins/sortable-YRMQX3ZU.js +0 -1
- package/dist/m/plugins/source-KHK5UUSK.js +0 -1
- package/dist/m/plugins/undo-OF2L3CXU.js +0 -1
- package/dist/m/plugins/upload-XSGNILUY.js +0 -1
- package/dist/m/plugins/visitor-drafts-XK5GW55Q.js +0 -21
- package/dist/m/plugins/visitor-drafts-owner-KSSH75EV.js +0 -1
- package/dist/m/plugins/wire-ZWVWKF76.js +0 -1
- package/dist/m/sync/live-sync-4AUVXVJU.js +0 -1
- /package/dist/m/plugins/{richclay-RC3L2F6X.js.LEGAL.txt → richclay-NRQDSWX6.js.LEGAL.txt} +0 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { state } from "../vendor/prosemirror.vendor.js";
|
|
2
|
+
|
|
3
|
+
// The update hooks a loaded view part registers for its editor view. The plugin
|
|
4
|
+
// below calls them on every document change, so a part keeps its blocks fresh
|
|
5
|
+
// without the editor importing it: the part adds its hook when it installs and
|
|
6
|
+
// removes it on dispose.
|
|
7
|
+
export const viewHooks = new WeakMap();
|
|
8
|
+
|
|
9
|
+
// The feature loader of each mounted editor, so an insert path can ask for a
|
|
10
|
+
// block's part before it draws the block.
|
|
11
|
+
export const viewFeatures = new WeakMap();
|
|
12
|
+
|
|
13
|
+
export function addViewHook(view, hook) {
|
|
14
|
+
const hooks = viewHooks.get(view) ?? new Set();
|
|
15
|
+
viewHooks.set(view, hooks);
|
|
16
|
+
hooks.add(hook);
|
|
17
|
+
return () => hooks.delete(hook);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function viewHookPlugin() {
|
|
21
|
+
return new state.Plugin({
|
|
22
|
+
view: (view) => ({
|
|
23
|
+
update: (next) => { for (const hook of viewHooks.get(next) ?? []) hook.update?.(next); },
|
|
24
|
+
destroy: () => {
|
|
25
|
+
const hooks = viewHooks.get(view);
|
|
26
|
+
if (!hooks) return;
|
|
27
|
+
for (const hook of hooks) hook.destroy?.(view);
|
|
28
|
+
hooks.clear();
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -3,16 +3,32 @@ import { NET, createPageDoc, walk, docOf } from "./core.js";
|
|
|
3
3
|
import { createBridge, keepViewPos } from "./bridge.js";
|
|
4
4
|
import { createYProvider, entriesOf, dec } from "./provider.js";
|
|
5
5
|
|
|
6
|
-
export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () => {}, ownedTokens = [], authorToken = null, setTimer = setTimeout, clearTimer = clearTimeout, compact, captureTimeout = 500, checkpointWithin = () => {}, requestCheckpoint = null, registerUnsavedState = null, onStopped = () => {} }) {
|
|
6
|
+
export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () => {}, ownedTokens = [], authorToken = null, setTimer = setTimeout, clearTimer = clearTimeout, compact, captureTimeout = 500, checkpointWithin = () => {}, requestCheckpoint = null, registerUnsavedState = null, onStopped = () => {}, journal = null }) {
|
|
7
7
|
for (const [, v] of entriesOf(store.value.y)) Y.applyUpdateV2(ydoc, dec(v), NET);
|
|
8
8
|
const pd = createPageDoc(ydoc);
|
|
9
|
-
|
|
9
|
+
// The journal is installed before the provider, because the provider is what sees an
|
|
10
|
+
// update: nothing can reach the document without passing it first. Its own code lives
|
|
11
|
+
// with the recovery channel and is only loaded when a page has one to journal into.
|
|
12
|
+
const pageJournal = journal || null;
|
|
13
|
+
const provider = createYProvider({
|
|
14
|
+
store,
|
|
15
|
+
ydoc,
|
|
16
|
+
setTimer,
|
|
17
|
+
clearTimer,
|
|
18
|
+
compact,
|
|
19
|
+
onStopped,
|
|
20
|
+
journal: { local: (u) => pageJournal?.local(u), dependency: (u) => pageJournal?.dependency(u) },
|
|
21
|
+
});
|
|
10
22
|
const stats = { groups: 0, mismatch: 0, renders: 0, first: null };
|
|
11
23
|
const bridge = createBridge({ pd, onNotice, ownedTokens, authorToken, stats, captureTimeout });
|
|
12
24
|
const waiters = new Set();
|
|
13
25
|
let disposed = false;
|
|
14
26
|
let checksQueued = false;
|
|
15
|
-
const unsaved = registerUnsavedState?.({
|
|
27
|
+
const unsaved = registerUnsavedState?.({
|
|
28
|
+
id: "page:live",
|
|
29
|
+
isPending: () => store.pending.length > 0 || provider.reconcilePending || Boolean(pageJournal && !pageJournal.failure && pageJournal.pending()),
|
|
30
|
+
flush,
|
|
31
|
+
});
|
|
16
32
|
const changed = () => {
|
|
17
33
|
unsaved?.changed();
|
|
18
34
|
if (checksQueued) return;
|
|
@@ -24,7 +40,13 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
|
|
|
24
40
|
};
|
|
25
41
|
const offLocal = store.onLocal(() => { checkpointWithin(1500); changed(); });
|
|
26
42
|
const offRebase = store.onRebase(changed);
|
|
27
|
-
const offConfirm = store.onConfirm(
|
|
43
|
+
const offConfirm = store.onConfirm(() => {
|
|
44
|
+
pageJournal?.acknowledged();
|
|
45
|
+
changed();
|
|
46
|
+
});
|
|
47
|
+
// Everything the journal reports while this session is the active one is unsaved state:
|
|
48
|
+
// a write in flight, a write the store refused, or a room that has not confirmed.
|
|
49
|
+
if (pageJournal) pageJournal.onChange = changed;
|
|
28
50
|
|
|
29
51
|
function flush({ timeoutMs = 10_000 } = {}) {
|
|
30
52
|
if (disposed) return Promise.resolve({ ok: false, code: "disposed" });
|
|
@@ -73,6 +95,7 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
|
|
|
73
95
|
|
|
74
96
|
return {
|
|
75
97
|
ydoc, pd, provider, bridge, stats, flush, awareness,
|
|
98
|
+
get journal() { return pageJournal; },
|
|
76
99
|
doc: () => keepViewPos(docOf(pd, walk(pd))),
|
|
77
100
|
attach: (view) => bridge.attach(view),
|
|
78
101
|
dispatch: (tr) => bridge.dispatch(tr),
|
|
@@ -92,6 +115,10 @@ export function createYPageBinding({ store, ydoc = new Y.Doc(), onNotice = () =>
|
|
|
92
115
|
offRebase();
|
|
93
116
|
offConfirm();
|
|
94
117
|
unsaved?.dispose();
|
|
118
|
+
// Best effort, never the primary path: every append already resolved on its own
|
|
119
|
+
// commit, and what is left is only a write the store refused.
|
|
120
|
+
pageJournal?.close();
|
|
121
|
+
Promise.resolve(pageJournal?.channel?.flush?.()).catch(() => {});
|
|
95
122
|
for (const waiter of [...waiters]) waiter.finish({ ok: false, code: "disposed" });
|
|
96
123
|
bridge.dispose();
|
|
97
124
|
provider.dispose();
|
package/src/page/ybind/marks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { schema } from "../schema.js";
|
|
2
2
|
import { entriesOfYjs, isOpaque, opaqueMark, yjsFields } from "../format-codec.js";
|
|
3
|
+
import { NATIVE_NAMES, nativeAttrs } from "../format-native.js";
|
|
3
4
|
|
|
4
5
|
export const markName = (k) => k.split("!")[0].split("~")[0];
|
|
5
6
|
export const isCancel = (k) => k.includes("!");
|
|
@@ -29,7 +30,7 @@ export function marksFromAttributes(a, blockType) {
|
|
|
29
30
|
const marks = [];
|
|
30
31
|
let unknown = null;
|
|
31
32
|
for (const [n, v] of best) {
|
|
32
|
-
if (schema.marks[n]) marks.push(schema.marks[n].create(v === true ? null : v));
|
|
33
|
+
if (schema.marks[n] && (!NATIVE_NAMES.has(n) || nativeAttrs(n, v))) marks.push(schema.marks[n].create(v === true ? null : v));
|
|
33
34
|
else (unknown ??= {})[n] = v;
|
|
34
35
|
}
|
|
35
36
|
// The walk asks this of every text run on every keystroke, and almost none carry a
|
|
@@ -11,6 +11,10 @@ export const enc = (u) => toBase64(u);
|
|
|
11
11
|
export const dec = (s) => fromBase64(s);
|
|
12
12
|
|
|
13
13
|
export const PART = 12 * 1024;
|
|
14
|
+
|
|
15
|
+
// The origin a replayed journal is applied through. It is offered to the room like any
|
|
16
|
+
// other local work and is never journaled again: a recovery is not this tab's typing.
|
|
17
|
+
export const RECOVERY_ORIGIN = "page-recovery";
|
|
14
18
|
const baseOf = (k) => PART_KEY.exec(k)?.[1] ?? k;
|
|
15
19
|
|
|
16
20
|
export function yValueOf(ydoc) {
|
|
@@ -80,7 +84,7 @@ const utf8Bytes = (text) => (encoder ? encoder.encode(text).length : text.length
|
|
|
80
84
|
const sameDeletes = (a, b) => Y.equalDeleteSets(Y.createDeleteSetFromStructStore(a.store), Y.createDeleteSetFromStructStore(b.store));
|
|
81
85
|
const svEqual = (a, b) => { if (a.size !== b.size) return false; for (const [c, n] of a) if (b.get(c) !== n) return false; return true; };
|
|
82
86
|
|
|
83
|
-
export function createYProvider({ store, ydoc, compact: limits = {}, setTimer = setTimeout, clearTimer = clearTimeout, onStopped = () => {}, stats = { applied: 0, offered: 0, compactions: 0 } }) {
|
|
87
|
+
export function createYProvider({ store, ydoc, compact: limits = {}, setTimer = setTimeout, clearTimer = clearTimeout, onStopped = () => {}, stats = { applied: 0, offered: 0, compactions: 0 }, journal = null }) {
|
|
84
88
|
const initial = entriesOf(store.value.y);
|
|
85
89
|
const known = new Set();
|
|
86
90
|
// Set when this tab's page can no longer reach the room: "lineage" when the room holds
|
|
@@ -116,7 +120,10 @@ export function createYProvider({ store, ydoc, compact: limits = {}, setTimer =
|
|
|
116
120
|
|
|
117
121
|
for (const [k] of initial) known.add(k);
|
|
118
122
|
|
|
123
|
+
// Ahead of the stop guards, and of the room's own delivery: work this tab can no longer
|
|
124
|
+
// offer, and work a refused offer took back, is still work a crash must not lose.
|
|
119
125
|
const onUpdate = (u, origin) => {
|
|
126
|
+
if (origin !== NET && origin !== ORIGINS_ORIGIN && origin !== RECOVERY_ORIGIN) journal?.local(u);
|
|
120
127
|
if (stopped || origin === NET || origin === ORIGINS_ORIGIN) return;
|
|
121
128
|
put(nextKey("u"), u);
|
|
122
129
|
};
|
|
@@ -127,7 +134,11 @@ export function createYProvider({ store, ydoc, compact: limits = {}, setTimer =
|
|
|
127
134
|
canCompact = true;
|
|
128
135
|
known.add(k);
|
|
129
136
|
stats.applied++;
|
|
130
|
-
|
|
137
|
+
const update = dec(v);
|
|
138
|
+
Y.applyUpdateV2(ydoc, update, NET);
|
|
139
|
+
// A received update can be the prerequisite of a later local insert, so the journal
|
|
140
|
+
// keeps it beside the work it owns, and never counts it as this tab's own.
|
|
141
|
+
journal?.dependency(update);
|
|
131
142
|
};
|
|
132
143
|
|
|
133
144
|
let wantReconcile = false;
|
|
@@ -16,9 +16,22 @@ import * as Y from "../../vendor/yjs.vendor.js";
|
|
|
16
16
|
import { toBase64 } from "../../vendor/yjs.vendor.js";
|
|
17
17
|
import { NET } from "./layout.js";
|
|
18
18
|
import { blocksFromYValue } from "./decode.js";
|
|
19
|
-
import { sameAuthority } from "../../recovery/identity.js";
|
|
20
|
-
import { sameLineage, sharesAncestry } from "./lineage.js";
|
|
21
|
-
import { canonicalPageBytes, containsUpdate } from "./recovery-proof.js";
|
|
19
|
+
import { sameAuthority, hostIdentityOf, pathIdentityOf } from "../../recovery/identity.js";
|
|
20
|
+
import { sameLineage, sharesAncestry, lineageOf } from "./lineage.js";
|
|
21
|
+
import { canonicalPageBytes, containsUpdate, digestHex, validatePageCheckpoint } from "./recovery-proof.js";
|
|
22
|
+
import { RECOVERY_ORIGIN } from "./provider.js";
|
|
23
|
+
|
|
24
|
+
/** The channel this adapter journals and replays: one name, one version, everywhere. */
|
|
25
|
+
export const PAGE_CHANNEL = "page.yjs";
|
|
26
|
+
export const PAGE_CHANNEL_VERSION = 1;
|
|
27
|
+
|
|
28
|
+
// When one writer's batches are merged into its capsule: past either threshold, and never
|
|
29
|
+
// over a record the store has not confirmed.
|
|
30
|
+
export const COMPACT_BATCHES = 64;
|
|
31
|
+
export const COMPACT_BYTES = 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/** The channel's own merge, for the binary store's compaction. */
|
|
34
|
+
export const mergePageUpdates = (parts) => Y.mergeUpdatesV2(parts);
|
|
22
35
|
|
|
23
36
|
export class PageReplayError extends Error {
|
|
24
37
|
constructor(code, message) {
|
|
@@ -125,6 +138,351 @@ export function admitCapsule({ capsule, batches = [], current } = {}) {
|
|
|
125
138
|
const stateBytes = canonicalPageBytes(current.state);
|
|
126
139
|
if (parts.base.length && !containsUpdate(stateBytes, parts.base)) fail("missing-base", "The page no longer contains the journal's base");
|
|
127
140
|
if (!parts.owned.length) fail("nothing-owned", "This capsule owns no local work");
|
|
128
|
-
|
|
141
|
+
// The reconstructed page is what has to share this page's ancestry, base included:
|
|
142
|
+
// this tab's own inserts carry its client alone, and never the page's.
|
|
143
|
+
const rebuilt = merge([parts.base, parts.dependencies, parts.owned]);
|
|
144
|
+
if (!sharesAncestry(current.state, { v: 1, g: 0, y: { seed: toBase64(rebuilt) } })) fail("foreign-lineage", "This journal shares no ancestry with this page");
|
|
129
145
|
return parts;
|
|
130
146
|
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One writer's capsules, each with the batches that belong to it.
|
|
150
|
+
*
|
|
151
|
+
* A session that died before its batches were compacted leaves them behind, so a
|
|
152
|
+
* capsule is only ever read together with every batch naming it, whichever session
|
|
153
|
+
* wrote it.
|
|
154
|
+
*
|
|
155
|
+
* @param {Array<Object>} [records] - what the channel listed
|
|
156
|
+
* @returns {Array<{capsule: Object, batches: Array<Object>}>}
|
|
157
|
+
*/
|
|
158
|
+
export function capsulesOf(records = []) {
|
|
159
|
+
const capsules = new Map();
|
|
160
|
+
const batches = new Map();
|
|
161
|
+
for (const record of records || []) {
|
|
162
|
+
if (!record || record.supported === false) continue;
|
|
163
|
+
if (record.kind === "binary") {
|
|
164
|
+
capsules.set(identityKey(record.id), record);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (record.kind !== "binary-batch" || record.capsuleId === undefined) continue;
|
|
168
|
+
const key = identityKey(record.capsuleId);
|
|
169
|
+
const list = batches.get(key) ?? [];
|
|
170
|
+
list.push(record);
|
|
171
|
+
batches.set(key, list);
|
|
172
|
+
}
|
|
173
|
+
return [...capsules.entries()].map(([key, capsule]) => ({ capsule, batches: batches.get(key) ?? [] }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Apply an admitted capsule's three parts into a live document.
|
|
178
|
+
*
|
|
179
|
+
* The recovery origin is its own: the provider offers the result to the room, and the
|
|
180
|
+
* journal never takes it as this tab's own work, so a recovery is never journaled twice.
|
|
181
|
+
*
|
|
182
|
+
* @param {Object} doc - the Y.Doc the page is being edited in
|
|
183
|
+
* @param {Object} parts - an `accumulateCapsule` result
|
|
184
|
+
*/
|
|
185
|
+
export function applyCapsule(doc, parts) {
|
|
186
|
+
for (const bytes of [parts.base, parts.dependencies, parts.owned]) {
|
|
187
|
+
if (bytes && bytes.length) Y.applyUpdateV2(doc, bytes, RECOVERY_ORIGIN);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Replay every capsule this page may replay, and name the ones it may not.
|
|
193
|
+
*
|
|
194
|
+
* Nothing is applied for a capsule that fails admission: a changed authority, a changed
|
|
195
|
+
* generation, a base the page no longer contains or no shared ancestry is review, not a
|
|
196
|
+
* merge. A capsule with no owned work has nothing to restore and is skipped silently.
|
|
197
|
+
*
|
|
198
|
+
* @param {{records?: Array<Object>, current: {context: Object, state: Object}, into: Object}} options
|
|
199
|
+
* @returns {{replayed: Array<Object>, review: Array<Object>}}
|
|
200
|
+
*/
|
|
201
|
+
export function replayJournals({ records = [], current, into } = {}) {
|
|
202
|
+
const replayed = [];
|
|
203
|
+
const review = [];
|
|
204
|
+
for (const entry of capsulesOf(records)) {
|
|
205
|
+
let parts;
|
|
206
|
+
try {
|
|
207
|
+
parts = admitCapsule({ capsule: entry.capsule, batches: entry.batches, current });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error?.code === "nothing-owned") continue;
|
|
210
|
+
review.push({ capsule: entry.capsule, code: error?.code ?? "invalid-capsule", message: error?.message ?? "This journal cannot be replayed" });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
applyCapsule(into, parts);
|
|
214
|
+
replayed.push({ id: entry.capsule.id, revision: parts.revision, owned: parts.owned.length });
|
|
215
|
+
}
|
|
216
|
+
return { replayed, review };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The context one episode's capsules are journaled under.
|
|
221
|
+
*
|
|
222
|
+
* A host that persists a page generation names it; without one, the generation is the
|
|
223
|
+
* digest of the accepted base, so a page rebuilt from other bytes is another generation
|
|
224
|
+
* and a page that has not changed keeps its own.
|
|
225
|
+
*
|
|
226
|
+
* @param {Object} options
|
|
227
|
+
* @param {{host: string, documentId: string, principalId: string}} options.authority
|
|
228
|
+
* @param {?string} options.generation
|
|
229
|
+
* @param {?Uint8Array} options.base - the accepted state captured at the episode's start
|
|
230
|
+
* @param {number} [options.formatFloor]
|
|
231
|
+
* @returns {Promise<Object>}
|
|
232
|
+
*/
|
|
233
|
+
export async function pageJournalContext({ authority, generation = null, base = null, formatFloor = 0 } = {}) {
|
|
234
|
+
const bytes = bytesOf(base) ?? new Uint8Array(0);
|
|
235
|
+
const token = typeof generation === "string" && generation ? generation : await digestHex(bytes);
|
|
236
|
+
return { authority, lineage: await lineageOf(token, bytes), formatFloor };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The authority this page journals under, or null when it cannot be pinned.
|
|
241
|
+
*
|
|
242
|
+
* A host that names people names the account too, and an answer without one is not a
|
|
243
|
+
* principal: replay waits for the next boot rather than filing work under a guess. A
|
|
244
|
+
* host with no people support gives its document to whoever holds it, so the document's
|
|
245
|
+
* own host identity is the principal there.
|
|
246
|
+
*
|
|
247
|
+
* @param {Object} [options]
|
|
248
|
+
* @param {?Object} [options.meta] - the host's discovery answer
|
|
249
|
+
* @param {?Location} [options.location]
|
|
250
|
+
* @param {?Element} [options.root]
|
|
251
|
+
* @returns {?{host: string, documentId: string, principalId: string}}
|
|
252
|
+
*/
|
|
253
|
+
export function pageAuthority({ meta = null, location: where = globalThis.location, root = globalThis.document?.documentElement } = {}) {
|
|
254
|
+
const host = where?.origin ?? "";
|
|
255
|
+
if (!host) return null;
|
|
256
|
+
const documentId = hostIdentityOf(root)?.identity ?? pathIdentityOf(where?.pathname ?? "/").identity;
|
|
257
|
+
if (!documentId) return null;
|
|
258
|
+
const people = meta?.document?.people;
|
|
259
|
+
const principalId = people && typeof people === "object" ? people.me?.id ?? null : documentId;
|
|
260
|
+
if (!principalId) return null;
|
|
261
|
+
return { host, documentId, principalId };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const journalKey = (id) => (typeof id === "string" ? id : JSON.stringify(id));
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The accepted page state an episode starts from, as the bytes its capsule keeps.
|
|
268
|
+
*
|
|
269
|
+
* @param {Object} state - a version 1 page value
|
|
270
|
+
* @returns {Uint8Array}
|
|
271
|
+
*/
|
|
272
|
+
export const journalBase = (state) => canonicalPageBytes(state);
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The page channel of one recovery service, registered once per page load.
|
|
276
|
+
*
|
|
277
|
+
* A channel is registered at its own version and nothing calls it until a page can
|
|
278
|
+
* actually journal: a reader and a page without a pinned identity register nothing.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} service - `clay.recovery`
|
|
281
|
+
* @returns {Object} the channel api
|
|
282
|
+
*/
|
|
283
|
+
const channels = new WeakMap();
|
|
284
|
+
export function pageChannel(service) {
|
|
285
|
+
if (!service) return null;
|
|
286
|
+
const existing = channels.get(service);
|
|
287
|
+
if (existing) return existing;
|
|
288
|
+
const channel = service.register({
|
|
289
|
+
channel: PAGE_CHANNEL,
|
|
290
|
+
version: PAGE_CHANNEL_VERSION,
|
|
291
|
+
validateCheckpoint: validatePageCheckpoint,
|
|
292
|
+
merge: mergePageUpdates,
|
|
293
|
+
});
|
|
294
|
+
channels.set(service, channel);
|
|
295
|
+
return channel;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One session's journal in the shared recovery store.
|
|
300
|
+
*
|
|
301
|
+
* Every local update is handed over as it happens: the bytes and the id are fixed
|
|
302
|
+
* synchronously, so a buffer reused after the call cannot change what was written, and
|
|
303
|
+
* the append resolves only once its transaction committed. Nothing here waits for the
|
|
304
|
+
* room, the network or the editor. Compaction names only the records the store has
|
|
305
|
+
* confirmed, because an append still in flight or refused has no revision to swap and
|
|
306
|
+
* must keep exactly the bytes it was given.
|
|
307
|
+
*
|
|
308
|
+
* @param {Object} options
|
|
309
|
+
* @param {Object} options.channel - the `page.yjs` channel api
|
|
310
|
+
* @param {string} options.key - the document this episode belongs to
|
|
311
|
+
* @param {Function|Object} options.context - (base) => Promise<context>, or the context
|
|
312
|
+
* @param {?Uint8Array} [options.base] - the accepted state the episode started from
|
|
313
|
+
* @returns {Object}
|
|
314
|
+
*/
|
|
315
|
+
export function createPageJournal({ channel, key, context, base = null, onChange = () => {} }) {
|
|
316
|
+
const records = new Map();
|
|
317
|
+
const waiting = [];
|
|
318
|
+
const inflight = new Set();
|
|
319
|
+
const bytesOfBase = bytesOf(base) ?? new Uint8Array(0);
|
|
320
|
+
let resolved = null;
|
|
321
|
+
let failure = null;
|
|
322
|
+
let batches = 0;
|
|
323
|
+
let batchBytes = 0;
|
|
324
|
+
let episode = false;
|
|
325
|
+
let compacting = null;
|
|
326
|
+
let closed = false;
|
|
327
|
+
|
|
328
|
+
const journal = {
|
|
329
|
+
channel,
|
|
330
|
+
key,
|
|
331
|
+
onChange,
|
|
332
|
+
context: () => ready,
|
|
333
|
+
local: (bytes) => send(bytes, "local"),
|
|
334
|
+
// Only while an episode is open: a peer's updates on their own are not this tab's work
|
|
335
|
+
// to keep, and a page that has journaled nothing owns nothing.
|
|
336
|
+
dependency: (bytes) => { if (episode) send(bytes, "dependency"); },
|
|
337
|
+
records: () => [...records.values()],
|
|
338
|
+
names,
|
|
339
|
+
pending: () => inflight.size + (failure ? 1 : 0),
|
|
340
|
+
get failure() { return failure; },
|
|
341
|
+
retire,
|
|
342
|
+
compact,
|
|
343
|
+
// An acknowledgement is metadata: it says a room holds the bytes and retires nothing.
|
|
344
|
+
acknowledged: () => {
|
|
345
|
+
if (closed || !resolved || !records.size || typeof channel.markAcknowledged !== "function") return Promise.resolve(null);
|
|
346
|
+
return Promise.resolve(channel.markAcknowledged({ records: names(), authority: resolved.authority })).catch(() => null);
|
|
347
|
+
},
|
|
348
|
+
close: () => {
|
|
349
|
+
closed = true;
|
|
350
|
+
waiting.length = 0;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const ready = Promise.resolve()
|
|
355
|
+
.then(() => (typeof context === "function" ? context(bytesOfBase) : context))
|
|
356
|
+
.catch(() => null)
|
|
357
|
+
.then((value) => {
|
|
358
|
+
resolved = value && value.authority ? value : null;
|
|
359
|
+
if (!resolved) {
|
|
360
|
+
closed = true;
|
|
361
|
+
failure = "unavailable";
|
|
362
|
+
waiting.length = 0;
|
|
363
|
+
journal.onChange();
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
for (const run of waiting.splice(0)) run(resolved);
|
|
367
|
+
return resolved;
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
function retire(list) {
|
|
371
|
+
for (const item of list || []) records.delete(journalKey(item?.id));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function names() {
|
|
375
|
+
return [...records.values()].map(({ id, revision }) => ({ id, revision }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function compact() {
|
|
379
|
+
if (compacting || closed || !records.size || typeof channel.compact !== "function") return compacting;
|
|
380
|
+
const named = names();
|
|
381
|
+
compacting = Promise.resolve()
|
|
382
|
+
.then(() => channel.compact({ records: named }))
|
|
383
|
+
.then((receipt) => {
|
|
384
|
+
if (!receipt?.ok) return receipt;
|
|
385
|
+
retire(receipt.retired);
|
|
386
|
+
batches = Math.max(0, batches - (receipt.retired?.length ?? 0));
|
|
387
|
+
if (!batches) batchBytes = 0;
|
|
388
|
+
return receipt;
|
|
389
|
+
})
|
|
390
|
+
.catch(() => null)
|
|
391
|
+
.then((receipt) => {
|
|
392
|
+
compacting = null;
|
|
393
|
+
journal.onChange();
|
|
394
|
+
return receipt;
|
|
395
|
+
});
|
|
396
|
+
return compacting;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function write(context, bytes, origin, first) {
|
|
400
|
+
let answer;
|
|
401
|
+
try {
|
|
402
|
+
answer = channel.append({ key, context, update: bytes, origin, base: first ? bytesOfBase : null });
|
|
403
|
+
} catch (_) {
|
|
404
|
+
failure = "error";
|
|
405
|
+
journal.onChange();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const token = {};
|
|
409
|
+
inflight.add(token);
|
|
410
|
+
Promise.resolve(answer).then(
|
|
411
|
+
(receipt) => {
|
|
412
|
+
inflight.delete(token);
|
|
413
|
+
if (!receipt || receipt.ok !== true) {
|
|
414
|
+
failure = receipt?.code ?? "error";
|
|
415
|
+
journal.onChange();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
failure = null;
|
|
419
|
+
if (!receipt.skipped) {
|
|
420
|
+
records.set(journalKey(receipt.id), { id: receipt.id, revision: receipt.revision, update: bytes, context });
|
|
421
|
+
batches += 1;
|
|
422
|
+
batchBytes += bytes.byteLength;
|
|
423
|
+
}
|
|
424
|
+
journal.onChange();
|
|
425
|
+
if (batches >= COMPACT_BATCHES || batchBytes >= COMPACT_BYTES) compact();
|
|
426
|
+
},
|
|
427
|
+
() => {
|
|
428
|
+
inflight.delete(token);
|
|
429
|
+
failure = "error";
|
|
430
|
+
journal.onChange();
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function send(bytes, origin) {
|
|
436
|
+
if (closed || !bytes || !bytes.byteLength || typeof channel.append !== "function") return;
|
|
437
|
+
const first = !episode;
|
|
438
|
+
episode = true;
|
|
439
|
+
if (resolved) write(resolved, bytes, origin, first);
|
|
440
|
+
else waiting.push((context) => write(context, bytes, origin, first));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return journal;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* This session's journal, opened from the host's own answer, or null.
|
|
448
|
+
*
|
|
449
|
+
* A host that cannot name who is writing, or a tab with no recovery service, journals
|
|
450
|
+
* nothing: a page that cannot pin its identity never replays across sessions.
|
|
451
|
+
*
|
|
452
|
+
* @param {Object} options
|
|
453
|
+
* @param {?Object} options.meta - the host's discovery answer
|
|
454
|
+
* @param {Object} options.service - `clay.recovery`
|
|
455
|
+
* @param {?Uint8Array} options.base - the accepted state captured before input
|
|
456
|
+
* @param {Function} [options.onChange]
|
|
457
|
+
* @returns {?Object}
|
|
458
|
+
*/
|
|
459
|
+
export function openPageJournal({ meta = null, service = null, base = null, onChange = () => {}, location, root } = {}) {
|
|
460
|
+
const authority = pageAuthority({ meta, location, root });
|
|
461
|
+
if (!authority) return null;
|
|
462
|
+
const channel = pageChannel(service);
|
|
463
|
+
if (!channel) return null;
|
|
464
|
+
const generation = meta?.document?.pageState?.generation ?? null;
|
|
465
|
+
const formatFloor = Array.isArray(meta?.extensions) && meta.extensions.includes("page-format-v1") ? 1 : 0;
|
|
466
|
+
return createPageJournal({
|
|
467
|
+
channel,
|
|
468
|
+
key: [authority.host, authority.documentId].join(":"),
|
|
469
|
+
context: (accepted) => pageJournalContext({ authority, generation, base: accepted, formatFloor }),
|
|
470
|
+
base,
|
|
471
|
+
onChange,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Replay this page's journal once, after the authority snapshot is adopted.
|
|
477
|
+
*
|
|
478
|
+
* @param {{journal: Object, state: Object, into: Object}} options
|
|
479
|
+
* @returns {Promise<{replayed: Array<Object>, review: Array<Object>}>}
|
|
480
|
+
*/
|
|
481
|
+
export async function replayInto({ journal, state, into } = {}) {
|
|
482
|
+
if (!journal) return { replayed: [], review: [] };
|
|
483
|
+
const context = await journal.context();
|
|
484
|
+
if (!context) return { replayed: [], review: [] };
|
|
485
|
+
const listed = await journal.channel.list();
|
|
486
|
+
if (!listed?.ok) return { replayed: [], review: [] };
|
|
487
|
+
return replayJournals({ records: listed.records, current: { context, state }, into });
|
|
488
|
+
}
|
package/src/plugins/live.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { contains, parseBy, frameSet, descendants, unionRanges, formatBy } from "../live/provenance.js";
|
|
9
9
|
import { createStore } from "../live/store.js";
|
|
10
10
|
import { serializeLive, stampOf, parseStamp, textHash } from "../live/ops.js";
|
|
11
|
-
import { onSnapshot } from "../core/snapshot.js";
|
|
11
|
+
import { capturePageState, onSnapshot } from "../core/snapshot.js";
|
|
12
12
|
import { onSaveAccepted, onSaveAttempt, onSaveOutcome, isSaveInProgress } from "../core/save-core.js";
|
|
13
13
|
import { savePageForce, baselineSettled, isSaveConflicted, autosaveIsSuspended } from "../core/save.js";
|
|
14
14
|
import { withKeepalive } from "../core/save-core.js";
|
|
@@ -1235,11 +1235,47 @@ onSaveOutcome(({ captureId, result, attempts, sent }) => {
|
|
|
1235
1235
|
if (plan) startHistoryRecovery(plan, { result, attempts, startedAt: Date.now() });
|
|
1236
1236
|
});
|
|
1237
1237
|
|
|
1238
|
+
// The recovery integration's turn at an accepted save, when one journals this page.
|
|
1239
|
+
// Registered by page code once it holds a `page.yjs` channel, so a page that journals
|
|
1240
|
+
// nothing loads neither the proof builder nor the conditional client, and pays one null
|
|
1241
|
+
// check per save.
|
|
1242
|
+
let pageCheckpoints = null;
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Hand every accepted save to a page checkpoint integration, or null to stop.
|
|
1246
|
+
*
|
|
1247
|
+
* The integration builds the proof from the capture's pinned page state and the bytes
|
|
1248
|
+
* the host took; nothing here decides what is covered. Its answer is not awaited: a save
|
|
1249
|
+
* that succeeded is saved whether or not a journal can be retired, and a failure there
|
|
1250
|
+
* must never reach the save's own outcome.
|
|
1251
|
+
*
|
|
1252
|
+
* @param {?Function} install - ({capture, state, article, etag}) => unknown
|
|
1253
|
+
*/
|
|
1254
|
+
export function registerPageCheckpoints(install) {
|
|
1255
|
+
pageCheckpoints = install;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function offerCheckpoint(captureId, html) {
|
|
1259
|
+
if (!pageCheckpoints) return;
|
|
1260
|
+
// Read synchronously, before this capture is released: the value is the one its page
|
|
1261
|
+
// tag carried, and the edits that followed it are not in these bytes.
|
|
1262
|
+
const state = capturePageState(captureId);
|
|
1263
|
+
if (!state) return;
|
|
1264
|
+
let article = null;
|
|
1265
|
+
try { article = parseHtml(html).querySelector("article[data-page]")?.innerHTML ?? null; }
|
|
1266
|
+
catch (_) { article = null; }
|
|
1267
|
+
const etag = lastSeenEtag();
|
|
1268
|
+
Promise.resolve()
|
|
1269
|
+
.then(() => pageCheckpoints({ capture: String(captureId), state, article, etag }))
|
|
1270
|
+
.catch(() => {});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1238
1273
|
// An accepted body confirms the ops of the capture that sent it, and only those.
|
|
1239
1274
|
// A null context (a raw save) cannot consume a captured pin.
|
|
1240
1275
|
function handleAccepted(html, context) {
|
|
1241
1276
|
const captureId = context?.captureId;
|
|
1242
1277
|
if (captureId === null || captureId === undefined) return;
|
|
1278
|
+
offerCheckpoint(captureId, html);
|
|
1243
1279
|
const pin = capturePins.get(captureId);
|
|
1244
1280
|
if (!pin) return;
|
|
1245
1281
|
dropCapture(captureId);
|