discrete doc: Codemirror+Yjs

master
Niko PLP 3 months ago
parent 9e6c004fc4
commit 434201c657
  1. 4
      Cargo.lock
  2. 5
      ng-app/package.json
  3. 7
      ng-app/src/api.ts
  4. 72
      ng-app/src/apps/CodeMirrorEditor.svelte
  5. 1
      ng-app/src/apps/ContainerView.svelte
  6. 0
      ng-app/src/apps/PreTextViewer.svelte
  7. 3
      ng-app/src/lib/Document.svelte
  8. 4
      ng-app/src/lib/FullLayout.svelte
  9. 19
      ng-app/src/lib/components/NavBar.svelte
  10. 3
      ng-app/src/routes/NURI.svelte
  11. 34
      ng-app/src/store.ts
  12. 50
      ng-app/src/tab.ts
  13. 2
      ng-app/src/zeras.ts
  14. 72
      ng-net/src/app_protocol.rs
  15. 3
      ng-repo/src/errors.rs
  16. 6
      ng-repo/src/types.rs
  17. 43
      ng-sdk-js/src/lib.rs
  18. 2
      ng-verifier/Cargo.toml
  19. 2
      ng-verifier/src/commits/mod.rs
  20. 164
      ng-verifier/src/commits/transaction.rs
  21. 69
      ng-verifier/src/request_processor.rs
  22. 16
      ng-verifier/src/rocksdb_user_storage.rs
  23. 71
      ng-verifier/src/types.rs
  24. 24
      ng-verifier/src/user_storage/branch.rs
  25. 29
      ng-verifier/src/user_storage/storage.rs
  26. 29
      ng-verifier/src/verifier.rs
  27. 58
      pnpm-lock.yaml

4
Cargo.lock generated

@ -7216,9 +7216,9 @@ dependencies = [
[[package]]
name = "yrs"
version = "0.18.2"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4058d69bbbc97181d53d9d093a4b892001b84601f2fc4e27f48c8862bc8b369"
checksum = "a8ca5126331b9a5ef5bb10f3f1c3d01b05f298d348c66f8fb15497d83ee73176"
dependencies = [
"arc-swap",
"atomic_refcell",

@ -18,6 +18,7 @@
"dependencies": {
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.2",
"@codemirror/legacy-modes": "^6.4.0",
"@codemirror/lint": "^6.8.1",
@ -39,7 +40,9 @@
"svelte-i18n": "^4.0.0",
"svelte-inview": "^4.0.2",
"svelte-spa-router": "^3.3.0",
"vite-plugin-top-level-await": "^1.3.1"
"vite-plugin-top-level-await": "^1.3.1",
"y-codemirror.next": "^0.3.5",
"yjs": "^13.6.18"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",

@ -222,6 +222,13 @@ const handler = {
let removes_json_str = new TextDecoder().decode(Uint8Array.from(event.payload.V0.Patch.graph.removes));
event.payload.V0.Patch.graph.removes = JSON.parse(removes_json_str);
}
if (event.payload.V0.State?.discrete) {
let crdt = Object.getOwnPropertyNames(event.payload.V0.State.discrete)[0];
event.payload.V0.State.discrete[crdt] = Uint8Array.from(event.payload.V0.State.discrete[crdt]);
} else if (event.payload.V0.Patch?.discrete) {
let crdt = Object.getOwnPropertyNames(event.payload.V0.Patch.discrete)[0];
event.payload.V0.Patch.discrete[crdt] = Uint8Array.from(event.payload.V0.Patch.discrete[crdt]);
}
let ret = callback(event.payload);
if (ret === true) {
await tauri.invoke("cancel_stream", {stream_id});

@ -0,0 +1,72 @@
<!--
// Copyright (c) 2022-2024 Niko Bonnieure, Par le Peuple, NextGraph.org developers
// All rights reserved.
// Licensed under the Apache License, Version 2.0
// <LICENSE-APACHE2 or http://www.apache.org/licenses/LICENSE-2.0>
// or the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>,
// at your option. All files in the project carrying such
// notice may not be copied, modified, or distributed except
// according to those terms.
-->
<script lang="ts">
import { onMount, tick, onDestroy } from "svelte";
import {
sparql_query,
toast_error,
toast_success,
reset_toasts,
display_error,
live_discrete_update
} from "../store";
import * as Y from 'yjs'
// @ts-ignore
import { yCollab } from 'y-codemirror.next'
import CodeMirror from "svelte-codemirror-editor";
import { javascript } from '@codemirror/lang-javascript'
import {basicSetup} from "codemirror"
export let commits = {};
const ydoc = new Y.Doc()
const ytext = ydoc.getText('ng')
ydoc.on('update', async (update, origin) => {
if (!origin.local) {
try {
await live_discrete_update(update, "YText", commits.heads);
} catch (e){
toast_error(display_error(e));
}
}
})
ydoc.on('destroy', () => {
commits.discrete?.deregisterOnUpdate();
})
onMount(()=>{
let history = commits.discrete?.registerOnUpdate((update) => {
Y.applyUpdate(ydoc, update.YText, {local:true})
});
for (const h of history) {
Y.applyUpdate(ydoc, h.YText, {local:true})
}
});
onDestroy(()=>{
ydoc.destroy();
});
</script>
<div class="flex-col">
<CodeMirror lang={javascript()} lineWrapping useTab={false} extensions={[basicSetup, yCollab(ytext, false, { undoManager: false })]} styles={{
"&": {
maxWidth: "100%",
},
}}/>
</div>

@ -21,7 +21,6 @@
function contained(graph) {
let ret = [];
for (const g of graph) {
console.log(g)
if (g.substring(104,137) === "http://www.w3.org/ns/ldp#contains") {
let nuri = g.substring(140,240);
let hash = nuri.substring(9,16);

@ -24,7 +24,7 @@
import { t } from "svelte-i18n";
import { Button, Progressbar, Spinner, Alert } from "flowbite-svelte";
import { inview } from 'svelte-inview';
import { cur_tab_doc_can_edit, nav_bar, can_have_header, header_icon, header_title, header_description, cur_branch, set_header_in_view, edit_header_button, cur_app, load_official_app, nav_bar_reset_newest } from "../tab";
import { cur_tab, cur_tab_doc_can_edit, nav_bar, can_have_header, header_icon, header_title, header_description, cur_branch, set_header_in_view, edit_header_button, cur_app, load_official_app, nav_bar_reset_newest } from "../tab";
import NavIcon from "./icons/NavIcon.svelte";
export let nuri = "";
@ -68,6 +68,7 @@
class:"w-8 h-8 mr-2 mb-2 flex-none focus:outline-none"
}}/>
{/if}
{#if !$header_title} <span class="font-mono h-8 py-1 inline-block align-middle mr-3"> {$cur_tab.doc.nuri.substring(2,9)} </span> {/if}
{#if $cur_tab_doc_can_edit}
<button class="p-1 mr-2 mb-2 w-8 h-8 flex-none" on:click={openEditHeader} title={$t($edit_header_button)}>
<Pencil tabindex=-1 class="w-5 h-5 focus:outline-none" />

@ -689,7 +689,7 @@
</MenuItem>
{/each}
{/if}
{#if open_edit_with}
{#if open_edit_with || $available_editors.length === 1}
<MenuItem title={get_app("n:g:z:upload_file")["ng:a"]} extraClass="submenu" clickable={ () => {openPane("files")} }>
<ZeraIcon
zera={get_app("n:g:z:upload_file")["ng:u"]}
@ -701,7 +701,7 @@
<span class="ml-3">{get_app("n:g:z:upload_file")["ng:n"]}</span>
</MenuItem>
{/if}
{#if !$cur_tab_view_or_edit || open_edit_with }
{#if (!$cur_tab_view_or_edit || open_edit_with) && !$cur_tab_graph_or_discrete }
<li title={$t("doc.menu.live_editing_description")} style="margin: 7px 0; padding-left: 32px;" class="toggle">
<Toggle
on:change={ toggle_live_edit }

@ -24,7 +24,8 @@
Popover,
} from "flowbite-svelte";
import {nav_bar_newest, save, nav_bar, showMenu, cur_tab_header_in_view, cur_tab_store_name_override, cur_tab_store_icon_override, cur_tab_persistent_error} from "../../tab";
import {nav_bar_newest, save, nav_bar, showMenu, cur_tab_header_in_view, cur_tab_store_name_override, cur_tab_store_icon_override, cur_tab_persistent_error, nav_bar_save,
cur_tab_view_or_edit, cur_tab_graph_or_discrete, nav_bar_back, nav_bar_title, nav_bar_icon} from "../../tab";
export let scrollToTop = () => {};
@ -40,21 +41,21 @@
</script>
<div style="background-color: #fbfbfb;" class="h-11 pb-1 flex text-center text-gray-700 dark:text-white">
{#if $nav_bar.back}
{#if $nav_bar_back}
<div role="button" tabindex="0" on:click={back} on:keypress={back} class="flex-none w-10 flex justify-center items-center">
<ArrowLeft tabindex="-1" class="w-8 h-8 focus:outline-none"/>
</div>
{/if}
{#if $cur_tab_store_icon_override || $nav_bar.icon}
<div style="cursor:pointer;" class:w-10={!$nav_bar.back} class:ml-3={!$nav_bar.back} class="flex-none w-8 m-1 " on:click={scrollToTop} on:keypress={scrollToTop}>
<NavIcon img={$cur_tab_store_icon_override || $nav_bar.icon} config={{
{#if $cur_tab_store_icon_override || $nav_bar_icon}
<div style="cursor:pointer;" class:w-10={!$nav_bar_back} class:ml-3={!$nav_bar_back} class="flex-none w-8 m-1 " on:click={scrollToTop} on:keypress={scrollToTop}>
<NavIcon img={$cur_tab_store_icon_override || $nav_bar_icon} config={{
tabindex:"-1",
class:"w-8 h-8 focus:outline-none"
}}/>
</div>
{/if}
<div style="cursor:pointer;" class:pl-3={!$nav_bar.back && !$nav_bar.icon} class="grow w-10 items-center flex px-1" on:click={scrollToTop} on:keypress={scrollToTop}>
<span class="inline-block truncate" > {$cur_tab_store_name_override || $nav_bar.title} </span>
<div style="cursor:pointer;" class:pl-3={!$nav_bar_back && !$nav_bar_icon} class="grow w-10 items-center flex px-1" on:click={scrollToTop} on:keypress={scrollToTop}>
<span class="inline-block truncate" > {$cur_tab_store_name_override || $nav_bar_title} </span>
</div>
{#if $nav_bar_newest && !$cur_tab_header_in_view}
<div role="button" tabindex="0" class="flex-none m-1 rounded-full bg-primary-700 text-white dark:bg-primary-700" on:click={scrollToTop} on:keypress={scrollToTop}>
@ -74,9 +75,9 @@
>{@html $cur_tab_persistent_error.desc}
<br/><br/><span class="text-primary-700" on:click={closeErrorPopup} on:keypress={closeErrorPopup} role="button" tabindex="0">Dismiss</span>
</Popover>
{:else if $nav_bar.save !== undefined}
{:else if $nav_bar_save !== undefined && !$cur_tab_view_or_edit && !$cur_tab_graph_or_discrete}
{#if $nav_bar.save }
{#if $nav_bar_save }
<div tabindex="0" class="flex-none w-10" role="button" on:click={save} on:keypress={save} title="Save">
<CheckCircle variation="solid" tabindex="-1" strokeWidth="3" class="w-10 h-10 text-primary-400 focus:outline-none"/>
</div>

@ -36,6 +36,9 @@
else if (params[1].startsWith("o:"+$active_session.protected_store_id)) push("#/shared");
else if (params[1].startsWith("o:"+$active_session.public_store_id)) push("#/site"); else nuri = params[1]; }
onMount(() => {
if ($cur_tab.store.store_type)
change_nav_bar(`nav:${$cur_tab.store.store_type}`,$t(`doc.${$cur_tab.store.store_type}_store`), true);
else
change_nav_bar("nav:unknown_doc",$t("doc.doc"), true);
reset_in_memory();
});

@ -395,6 +395,28 @@ export const digest_to_string = function(digest) {
return encode(buffer.buffer);
};
export const discrete_update = async (update) => {
// if cur_tab.doc.live_edit => send directly to verifier (with live_discrete_update)
// else, save the update locally with the API.
// and nav_bar.update((o) => { o.save = true; return o; });
// once save button is pressed, we call OnSave with all the updates that we retrieve from local storage (via API). and we set nav_bar.update((o) => { o.save = false; return o; });
// the editor then process those updates and calls live_discrete_update
}
export const live_discrete_update = async (update, crdt, heads) => {
// send directly to verifier with AppRequest Update
let session = get(active_session);
if (!session) {
persistent_error(get(cur_branch), {
title: get(format)("doc.errors.no_session"),
desc: get(format)("doc.errors_details.no_session")
});
throw new Error("no session");
}
let nuri = "did:ng:"+get(cur_tab).branch.nuri;
await ng.discrete_update(session.session_id, update, heads, crdt, nuri);
}
export const sparql_query = async function(sparql:string, union:boolean) {
let session = get(active_session);
if (!session) {
@ -472,10 +494,12 @@ export const branch_subscribe = function(nuri:string, in_tab:boolean) {
//console.log("sub");
let already_subscribed = all_branches[nuri];
if (!already_subscribed) {
const { subscribe, set, update } = writable({graph:[], discrete:[], files:[], history: {start:()=>{}, stop:()=>{}, commits:false}, heads: []}); // create the underlying writable store // take:()=>{},
let onUpdate = (update) => {};
const { subscribe, set, update } = writable({graph:[], discrete:{updates:[], deregisterOnUpdate:()=>{ onUpdate=()=>{};},registerOnUpdate:(f)=>{ }}, files:[], history: {start:()=>{}, stop:()=>{}, commits:false}, heads: []}); // create the underlying writable store // take:()=>{},
update((old)=> {
old.history.start = () => update((o) => {o.history.commits = true; return o;}) ;
old.history.stop = () => update((o) => {o.history.commits = false; return o;}) ;
old.discrete.registerOnUpdate = (f) => { onUpdate = f; return get({subscribe}).discrete.updates; };
//old.history.take = () => { let res: boolean | Array<{}> = false; update((o) => {res = o.history.commits; o.history.commits = []; return o;}); return res;}
return old;});
let count = 0;
@ -562,6 +586,10 @@ export const branch_subscribe = function(nuri:string, in_tab:boolean) {
}
old.graph.sort();
}
if (response.V0.State.discrete) {
old.discrete.updates.push(response.V0.State.discrete);
onUpdate(response.V0.State.discrete);
}
tab_update(nuri, ($cur_tab) => {
$cur_tab.branch.files = old.files.length;
return $cur_tab;
@ -582,6 +610,10 @@ export const branch_subscribe = function(nuri:string, in_tab:boolean) {
old.history.commits.push(commit);
}
}
if (response.V0.Patch.discrete) {
old.discrete.updates.push(response.V0.Patch.discrete);
onUpdate(response.V0.Patch.discrete);
}
if (response.V0.Patch.graph) {
let duplicates = [];
for (let i = 0; i < old.graph.length; i++) {

@ -260,6 +260,8 @@ export const all_tabs = writable({
description: "",
app: "", // current app being used
onSave: (updates) => {},
updates: [],
},
view_or_edit: true, // true=> view, false=> edit
graph_viewer: "", // selected viewer
@ -304,6 +306,13 @@ export const all_tabs = writable({
}
});
export const cur_tab_register_on_save = (f:(updates) => {}) => {
cur_tab_update((old)=>{
old.branch.onSave = f;
return old;
});
}
export const set_header_in_view = function(val) {
cur_tab_update((old) => { old.header_in_view = val; return old;});
}
@ -427,13 +436,6 @@ export const cur_tab_update = function( fn ) {
export const live_editing = writable(false);
live_editing.subscribe((val) => {
cur_tab_update((old)=> {
old.doc.live_edit = val;
return old;
});
});
export const showMenu = () => {
show_modal_menu.set(true);
cur_tab_update(ct => {
@ -458,13 +460,42 @@ export const nav_bar = writable({
back: false,
newest: 0,
save: undefined,
toasts: [],
});
live_editing.subscribe((val) => {
cur_tab_update((old)=> {
old.doc.live_edit = val;
if (val) {
//TODO: send all the updates with live_discrete_update
}
nav_bar.update((o) => {
o.save = old.doc.live_edit ? undefined : ( old.branch.updates.length > 0 ? true : false )
return o;
});
return old;
});
});
export const nav_bar_newest = derived(nav_bar, ($nav_bar) => {
return $nav_bar.newest;
});
export const nav_bar_save = derived(nav_bar, ($nav_bar) => {
return $nav_bar.save;
});
export const nav_bar_back = derived(nav_bar, ($nav_bar) => {
return $nav_bar.back;
});
export const nav_bar_title = derived(nav_bar, ($nav_bar) => {
return $nav_bar.title;
});
export const nav_bar_icon = derived(nav_bar, ($nav_bar) => {
return $nav_bar.icon;
});
export const nav_bar_reset_newest = () => {
nav_bar.update((old) => {
old.newest = 0;
@ -496,6 +527,8 @@ export const persistent_error = (nuri, pe) => {
export const save = async () => {
// saving the doc
// fetch updates from local storage
get(cur_tab).branch.onSave([]);
}
export const all_files_count = derived(cur_tab, ($cur_tab) => {
@ -515,6 +548,7 @@ export const has_editor_chat = derived(cur_tab, ($cur_tab) => {
export const toggle_live_edit = () => {
cur_tab_update(ct => {
ct.doc.live_edit = !ct.doc.live_edit;
live_editing.set(ct.doc.live_edit);
return ct;
});
}

@ -226,7 +226,7 @@ export const official_apps = {
"ng:w": ["post:md"],
},
"n:g:z:code_editor": {
"ng:n": "Code and Text Editor",
"ng:n": "Text Editor",
"ng:a": "Edit the code/text with CodeMirror",
"ng:c": "app",
"ng:u": "edit",//favicon. can be a did:ng:j

@ -43,12 +43,12 @@ lazy_static! {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum AppFetchContentV0 {
Get, // does not subscribe. more to be detailed
Subscribe, // more to be detailed
Get, // does not subscribe.
Subscribe,
Update,
//Invoke,
ReadQuery, // more to be detailed
WriteQuery, // more to be detailed
ReadQuery,
WriteQuery,
RdfDump,
History,
}
@ -92,6 +92,13 @@ impl TargetBranchV0 {
_ => true,
}
}
pub fn is_valid_for_discrete_update(&self) -> bool {
match self {
Self::BranchId(_) => true,
//TODO: add Named(s) is s is a branch => true
_ => false,
}
}
pub fn branch_id(&self) -> &BranchId {
match self {
Self::BranchId(id) => id,
@ -124,6 +131,12 @@ impl NuriTargetV0 {
_ => true,
}
}
pub fn is_valid_for_discrete_update(&self) -> bool {
match self {
Self::UserSite | Self::AllDialogs | Self::AllGroups | Self::None => false,
_ => true,
}
}
pub fn is_repo_id(&self) -> bool {
match self {
Self::Repo(_) => true,
@ -276,6 +289,15 @@ impl NuriV0 {
.as_ref()
.map_or(true, |b| b.is_valid_for_sparql_update())
}
pub fn is_valid_for_discrete_update(&self) -> bool {
self.object.is_none()
&& self.entire_store == false
&& self.target.is_valid_for_discrete_update()
&& self
.branch
.as_ref()
.map_or(true, |b| b.is_valid_for_discrete_update())
}
pub fn new_repo_target_from_string(repo_id_string: String) -> Result<Self, NgError> {
let repo_id: RepoId = repo_id_string.as_str().try_into()?;
Ok(Self {
@ -460,6 +482,9 @@ impl AppRequestCommandV0 {
pub fn new_write_query() -> Self {
AppRequestCommandV0::Fetch(AppFetchContentV0::WriteQuery)
}
pub fn new_update() -> Self {
AppRequestCommandV0::Fetch(AppFetchContentV0::Update)
}
pub fn new_rdf_dump() -> Self {
AppRequestCommandV0::Fetch(AppFetchContentV0::RdfDump)
}
@ -616,11 +641,24 @@ pub enum DiscreteUpdate {
Automerge(Vec<u8>),
}
impl DiscreteUpdate {
pub fn from(crdt: String, update: Vec<u8>) -> Self {
match crdt.as_str() {
"YMap" => Self::YMap(update),
"YArray" => Self::YArray(update),
"YXml" => Self::YXml(update),
"YText" => Self::YText(update),
"Automerge" => Self::Automerge(update),
_ => panic!("wrong crdt type"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DocUpdate {
heads: Vec<ObjectId>,
graph: Option<GraphUpdate>,
discrete: Option<DiscreteUpdate>,
pub heads: Vec<ObjectId>,
pub graph: Option<GraphUpdate>,
pub discrete: Option<DiscreteUpdate>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -683,6 +721,24 @@ impl AppRequestPayload {
pub fn new_sparql_query(query: String) -> Self {
AppRequestPayload::V0(AppRequestPayloadV0::Query(DocQuery::V0(query)))
}
pub fn new_discrete_update(
head_strings: Vec<String>,
crdt: String,
update: Vec<u8>,
) -> Result<Self, NgError> {
let mut heads = Vec::with_capacity(head_strings.len());
for head in head_strings {
heads.push(decode_digest(&head)?);
}
let discrete = Some(DiscreteUpdate::from(crdt, update));
Ok(AppRequestPayload::V0(AppRequestPayloadV0::Update(
DocUpdate {
heads,
graph: None,
discrete,
},
)))
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -713,7 +769,7 @@ pub struct GraphPatch {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DiscreteState {
/// A yrs::StateVector
/// A yrs::Update
#[serde(with = "serde_bytes")]
YMap(Vec<u8>),
#[serde(with = "serde_bytes")]

@ -226,6 +226,7 @@ pub enum StorageError {
NotEmpty,
ServerAlreadyRunningInOtherProcess,
NgError(String),
NoDiscreteState,
}
impl core::fmt::Display for StorageError {
@ -367,6 +368,8 @@ pub enum VerifierError {
OxigraphError(String),
CannotRemoveTriplesWhenNewBranch,
PermissionDenied,
YrsError(String),
InvalidNuri,
}
impl Error for VerifierError {}

@ -1366,6 +1366,12 @@ pub enum BranchCrdt {
}
impl BranchCrdt {
pub fn is_graph(&self) -> bool {
match self {
BranchCrdt::Graph(_) => true,
_ => false,
}
}
pub fn name(&self) -> String {
match self {
BranchCrdt::Graph(_) => "Graph",

@ -314,6 +314,49 @@ pub async fn sparql_query(
}
}
#[wasm_bindgen]
pub async fn discrete_update(
session_id: JsValue,
update: JsValue,
heads: Array,
crdt: String,
nuri: String,
) -> Result<(), String> {
let session_id: u64 = serde_wasm_bindgen::from_value::<u64>(session_id)
.map_err(|_| "Invalid session_id".to_string())?;
let nuri = NuriV0::new_from(&nuri).map_err(|e| e.to_string())?;
let mut head_strings = Vec::with_capacity(heads.length() as usize);
for head in heads.iter() {
if let Some(s) = head.as_string() {
head_strings.push(s)
} else {
return Err("Invalid HEADS".to_string());
}
}
let update: serde_bytes::ByteBuf =
serde_wasm_bindgen::from_value::<serde_bytes::ByteBuf>(update)
.map_err(|_| "Deserialization error of update".to_string())?;
let request = AppRequest::V0(AppRequestV0 {
command: AppRequestCommandV0::new_update(),
nuri,
payload: Some(
AppRequestPayload::new_discrete_update(head_strings, crdt, update.into_vec())
.map_err(|e| format!("Deserialization error of heads: {e}"))?,
),
session_id,
});
let res = nextgraph::local_broker::app_request(request)
.await
.map_err(|e: NgError| e.to_string())?;
if let AppResponse::V0(AppResponseV0::Error(e)) = res {
Err(e)
} else {
Ok(())
}
}
#[wasm_bindgen]
pub async fn sparql_update(
session_id: JsValue,

@ -30,7 +30,7 @@ futures = "0.3.24"
async-trait = "0.1.64"
async-std = { version = "1.12.0", features = [ "attributes", "unstable" ] }
automerge = "0.5.9"
yrs = "0.18.2"
yrs = "0.19.2"
sbbf-rs-safe = "0.3.2"
ng-repo = { path = "../ng-repo", version = "0.1.0-preview.1" }
ng-net = { path = "../ng-net", version = "0.1.0-preview.1" }

@ -574,9 +574,11 @@ impl CommitVerifier for AddRepo {
let remote = (&verifier.connected_broker).into();
let user = Some(verifier.user_id().clone());
let read_cap = self.read_cap();
let overlay_id = store.overlay_id;
verifier
.load_repo_from_read_cap(read_cap, &broker, &user, &remote, store, true)
.await?;
verifier.add_doc(repo_id, &overlay_id)?;
Ok(())
}
}

@ -14,7 +14,10 @@ use std::sync::Arc;
use ng_oxigraph::oxigraph::storage_ng::numeric_encoder::{EncodedQuad, EncodedTerm};
use ng_oxigraph::oxigraph::storage_ng::*;
use ng_repo::repo::Repo;
use serde::{Deserialize, Serialize};
use yrs::updates::decoder::Decode;
use yrs::{ReadTxn, StateVector, Transact, Update};
use ng_net::app_protocol::*;
use ng_oxigraph::oxrdf::{GraphName, GraphNameRef, NamedNode, Quad, Triple, TripleRef};
@ -23,53 +26,9 @@ use ng_repo::log::*;
use ng_repo::store::Store;
use ng_repo::types::*;
use crate::types::*;
use crate::verifier::Verifier;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GraphTransaction {
pub inserts: Vec<Triple>,
pub removes: Vec<Triple>,
}
impl GraphTransaction {
fn as_patch(&self) -> GraphPatch {
GraphPatch {
inserts: serde_bare::to_vec(&self.inserts).unwrap(),
removes: serde_bare::to_vec(&self.removes).unwrap(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DiscreteTransaction {
/// A yrs::Update
#[serde(with = "serde_bytes")]
YMap(Vec<u8>),
#[serde(with = "serde_bytes")]
YArray(Vec<u8>),
#[serde(with = "serde_bytes")]
YXml(Vec<u8>),
#[serde(with = "serde_bytes")]
YText(Vec<u8>),
/// An automerge::Patch
#[serde(with = "serde_bytes")]
Automerge(Vec<u8>),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TransactionBodyType {
Graph,
Discrete,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransactionBody {
body_type: TransactionBodyType,
graph: Option<GraphTransaction>,
discrete: Option<DiscreteTransaction>,
}
struct BranchUpdateInfo {
branch_id: BranchId,
branch_is_main: bool,
@ -208,6 +167,77 @@ impl Verifier {
.map_err(|e| VerifierError::OxigraphError(e.to_string()))
}
pub(crate) async fn update_discrete(
&mut self,
patch: DiscreteTransaction,
crdt: &BranchCrdt,
branch_id: &BranchId,
commit_id: ObjectId,
commit_info: CommitInfoJs,
) -> Result<(), VerifierError> {
let new_state = if let Ok(state) = self
.user_storage
.as_ref()
.unwrap()
.branch_get_discrete_state(branch_id)
{
match crdt {
BranchCrdt::Automerge(_) => {
unimplemented!();
}
BranchCrdt::YArray(_)
| BranchCrdt::YMap(_)
| BranchCrdt::YText(_)
| BranchCrdt::YXml(_) => {
let doc = yrs::Doc::new();
{
let mut txn = doc.transact_mut();
let update = yrs::Update::decode_v1(&state)
.map_err(|e| VerifierError::YrsError(e.to_string()))?;
txn.apply_update(update);
let update = yrs::Update::decode_v1(&patch.to_vec())
.map_err(|e| VerifierError::YrsError(e.to_string()))?;
txn.apply_update(update);
txn.commit();
}
let empty_state_vector = yrs::StateVector::default();
let transac = doc.transact();
transac.encode_state_as_update_v1(&empty_state_vector)
}
_ => return Err(VerifierError::InvalidBranch),
}
} else {
patch.to_vec()
};
self.user_storage
.as_ref()
.unwrap()
.branch_set_discrete_state(*branch_id, new_state)?;
let patch = match (crdt, patch) {
(BranchCrdt::Automerge(_), DiscreteTransaction::Automerge(v)) => {
DiscretePatch::Automerge(v)
}
(BranchCrdt::YArray(_), DiscreteTransaction::YArray(v)) => DiscretePatch::YArray(v),
(BranchCrdt::YMap(_), DiscreteTransaction::YMap(v)) => DiscretePatch::YMap(v),
(BranchCrdt::YText(_), DiscreteTransaction::YText(v)) => DiscretePatch::YText(v),
(BranchCrdt::YXml(_), DiscreteTransaction::YXml(v)) => DiscretePatch::YXml(v),
_ => return Err(VerifierError::InvalidCommit),
};
self.push_app_response(
branch_id,
AppResponse::V0(AppResponseV0::Patch(AppPatch {
commit_id: commit_id.to_string(),
commit_info: commit_info,
graph: None,
discrete: Some(patch),
other: None,
})),
)
.await;
Ok(())
}
pub(crate) async fn verify_async_transaction(
&mut self,
transaction: &Transaction,
@ -239,12 +269,54 @@ impl Verifier {
commit_info,
};
self.update_graph(vec![info]).await?;
} else
//TODO: change the logic here. transaction commits can have both a discrete and graph update. Only one AppResponse should be sent in this case, containing both updates.
if body.discrete.is_some() {
let patch = body.discrete.unwrap();
let crdt = &repo.branch(branch_id)?.crdt.clone();
self.update_discrete(patch, &crdt, branch_id, commit_id, commit_info)
.await?;
}
//TODO: discrete update
Ok(())
}
// pub(crate) fn find_branch_and_repo_for_nuri(
// &self,
// nuri: &NuriV0,
// ) -> Result<(RepoId, BranchId, StoreRepo), VerifierError> {
// if !nuri.is_branch_identifier() {
// return Err(VerifierError::InvalidNuri);
// }
// let store = self.get_store_by_overlay_id(&OverlayId::Outer(
// nuri.overlay.as_ref().unwrap().outer().to_slice(),
// ))?;
// let repo = self.get_repo(nuri.target.repo_id(), store.get_store_repo())?;
// Ok((
// match nuri.branch {
// None => {
// let b = repo.main_branch().ok_or(VerifierError::BranchNotFound)?;
// if b.topic_priv_key.is_none() {
// return Err(VerifierError::PermissionDenied);
// }
// b.id
// }
// Some(TargetBranchV0::BranchId(id)) => {
// let b = repo.branch(&id)?;
// //TODO: deal with named branch that is also the main branch
// if b.topic_priv_key.is_none() {
// return Err(VerifierError::PermissionDenied);
// }
// id
// }
// // TODO: implement TargetBranchV0::Named
// _ => unimplemented!(),
// },
// repo.id,
// store.get_store_repo().clone(),
// ))
// }
fn find_branch_and_repo_for_quad(
&self,
quad: &Quad,

@ -266,12 +266,11 @@ impl Verifier {
.await?;
// adding an ldp:contains triple to the store main branch
let nuri = NuriV0::repo_graph_name(&repo_id, &doc_create.store.outer_overlay());
let overlay_id = doc_create.store.outer_overlay();
let nuri = NuriV0::repo_graph_name(&repo_id, &overlay_id);
let store_nuri = NuriV0::from_store_repo(&doc_create.store);
let store_nuri_string = NuriV0::repo_graph_name(
doc_create.store.repo_id(),
&doc_create.store.outer_overlay(),
);
let store_nuri_string =
NuriV0::repo_graph_name(doc_create.store.repo_id(), &overlay_id);
let query = format!("INSERT DATA {{ <{store_nuri_string}> <http://www.w3.org/ns/ldp#contains> <{nuri}>. }}");
let ret = self.process_sparql_update(&store_nuri, &query).await;
@ -279,6 +278,8 @@ impl Verifier {
return Ok(AppResponse::error(e));
}
self.add_doc(&repo_id, &overlay_id)?;
return Ok(AppResponse::V0(AppResponseV0::Nuri(nuri)));
} else {
return Err(NgError::InvalidPayload);
@ -333,6 +334,64 @@ impl Verifier {
Err(NgError::InvalidPayload)
};
}
AppFetchContentV0::Update => {
if !nuri.is_valid_for_discrete_update() {
return Err(NgError::InvalidNuri);
}
return if let Some(AppRequestPayload::V0(AppRequestPayloadV0::Update(update))) =
payload
{
//TODO: deal with update.graph
//TODO: verify that update.heads are the same as what the Verifier knows
if let Some(discrete) = update.discrete {
let (repo_id, branch_id, store_repo) =
match self.resolve_target(&nuri.target) {
Err(e) => return Ok(AppResponse::error(e.to_string())),
Ok(a) => a,
};
let patch: DiscreteTransaction = discrete.into();
let transac = TransactionBody {
body_type: TransactionBodyType::Discrete,
graph: None,
discrete: Some(patch.clone()),
};
let transaction_commit_body = CommitBodyV0::AsyncTransaction(
Transaction::V0(serde_bare::to_vec(&transac)?),
);
let commit = self
.new_transaction_commit(
transaction_commit_body,
&repo_id,
&branch_id,
&store_repo,
vec![], //TODO deps
vec![],
)
.await?;
let repo = self.get_repo(&repo_id, &store_repo)?;
let commit_info: CommitInfoJs = (&commit.as_info(repo)).into();
let crdt = &repo.branch(&branch_id)?.crdt.clone();
self.update_discrete(
patch,
&crdt,
&branch_id,
commit.id().unwrap(),
commit_info,
)
.await?;
}
Ok(AppResponse::ok())
} else {
Err(NgError::InvalidPayload)
};
}
AppFetchContentV0::RdfDump => {
let store = self.graph_dataset.as_ref().unwrap();

@ -95,6 +95,22 @@ impl UserStorage for RocksDbUserStorage {
}
}
fn branch_set_discrete_state(
&self,
branch: BranchId,
state: Vec<u8>,
) -> Result<(), StorageError> {
let branch = BranchStorage::open(&branch, &self.user_storage)?;
branch.set_discrete_state(state)
}
fn branch_get_discrete_state(&self, branch: &BranchId) -> Result<Vec<u8>, StorageError> {
let branch = BranchStorage::new(&branch, &self.user_storage)?;
branch
.get_discrete_state()
.map_err(|_| StorageError::NoDiscreteState)
}
fn branch_add_file(
&self,
commit_id: ObjectId,

@ -18,9 +18,78 @@ use serde::{Deserialize, Serialize};
//use oxigraph::model::GroundQuad;
//use yrs::{StateVector, Update};
use ng_net::{app_protocol::*, types::*};
use ng_oxigraph::oxrdf::{GraphName, GraphNameRef, NamedNode, Quad, Triple, TripleRef};
use ng_repo::{errors::*, types::*};
use ng_net::types::*;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GraphTransaction {
pub inserts: Vec<Triple>,
pub removes: Vec<Triple>,
}
impl GraphTransaction {
pub(crate) fn as_patch(&self) -> GraphPatch {
GraphPatch {
inserts: serde_bare::to_vec(&self.inserts).unwrap(),
removes: serde_bare::to_vec(&self.removes).unwrap(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DiscreteTransaction {
/// A serialization of a yrs::Update
#[serde(with = "serde_bytes")]
YMap(Vec<u8>),
#[serde(with = "serde_bytes")]
YArray(Vec<u8>),
#[serde(with = "serde_bytes")]
YXml(Vec<u8>),
#[serde(with = "serde_bytes")]
YText(Vec<u8>),
/// An automerge::Patch
#[serde(with = "serde_bytes")]
Automerge(Vec<u8>),
}
impl From<DiscreteUpdate> for DiscreteTransaction {
fn from(update: DiscreteUpdate) -> Self {
match update {
DiscreteUpdate::Automerge(v) => DiscreteTransaction::Automerge(v),
DiscreteUpdate::YMap(v) => DiscreteTransaction::YMap(v),
DiscreteUpdate::YArray(v) => DiscreteTransaction::YArray(v),
DiscreteUpdate::YXml(v) => DiscreteTransaction::YXml(v),
DiscreteUpdate::YText(v) => DiscreteTransaction::YText(v),
}
}
}
impl DiscreteTransaction {
pub fn to_vec(&self) -> Vec<u8> {
match self {
Self::YMap(v)
| Self::YArray(v)
| Self::YXml(v)
| Self::YText(v)
| Self::Automerge(v) => v.to_vec(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TransactionBodyType {
Graph,
Discrete,
Both,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransactionBody {
pub body_type: TransactionBodyType,
pub graph: Option<GraphTransaction>,
pub discrete: Option<DiscreteTransaction>,
}
#[doc(hidden)]
#[derive(Clone, Debug, Serialize, Deserialize)]

@ -42,8 +42,9 @@ impl<'a> BranchStorage<'a> {
const MERGED_IN: u8 = b'm';
const CRDT: u8 = b'd';
const CLASS: u8 = b'c';
const DISCRETE_STATE: u8 = b's';
const ALL_PROPERTIES: [u8; 9] = [
const ALL_PROPERTIES: [u8; 10] = [
Self::TYPE,
Self::PUBLISHER,
Self::READ_CAP,
@ -53,6 +54,7 @@ impl<'a> BranchStorage<'a> {
Self::MERGED_IN,
Self::CRDT,
Self::CLASS,
Self::DISCRETE_STATE,
];
const PREFIX_HEADS: u8 = b'h';
@ -220,6 +222,26 @@ impl<'a> BranchStorage<'a> {
Ok(res)
}
pub fn set_discrete_state(&self, state: Vec<u8>) -> Result<(), StorageError> {
self.storage.write_transaction(&mut |tx| {
let id_ser = &to_vec(&self.id)?;
tx.put(
Self::PREFIX,
&id_ser,
Some(Self::DISCRETE_STATE),
&state,
&None,
)?;
Ok(())
})
}
pub fn get_discrete_state(&self) -> Result<Vec<u8>, StorageError> {
let id_ser = &to_vec(&self.id)?;
self.storage
.get(Self::PREFIX, &id_ser, Some(Self::DISCRETE_STATE), &None)
}
pub fn add_file(&self, commit_id: &ObjectId, file: &FileName) -> Result<(), StorageError> {
self.storage.write_transaction(&mut |tx| {
let branch_id_ser = to_vec(&self.id)?;

@ -51,6 +51,14 @@ pub trait UserStorage: Send + Sync {
fn branch_get_all_files(&self, branch: &BranchId) -> Result<Vec<FileName>, StorageError>;
fn branch_set_discrete_state(
&self,
branch: BranchId,
state: Vec<u8>,
) -> Result<(), StorageError>;
fn branch_get_discrete_state(&self, branch: &BranchId) -> Result<Vec<u8>, StorageError>;
fn branch_get_tab_info(
&self,
branch: &BranchId,
@ -68,12 +76,14 @@ pub trait UserStorage: Send + Sync {
pub(crate) struct InMemoryUserStorage {
branch_files: RwLock<HashMap<BranchId, Vec<FileName>>>,
branch_discrete_state: RwLock<HashMap<BranchId, Vec<u8>>>,
}
impl InMemoryUserStorage {
pub fn new() -> Self {
InMemoryUserStorage {
branch_files: RwLock::new(HashMap::new()),
branch_discrete_state: RwLock::new(HashMap::new()),
}
}
}
@ -100,6 +110,25 @@ impl UserStorage for InMemoryUserStorage {
}
}
fn branch_set_discrete_state(
&self,
branch: BranchId,
state: Vec<u8>,
) -> Result<(), StorageError> {
let mut lock = self.branch_discrete_state.write().unwrap();
let _ = lock.insert(branch, state);
Ok(())
}
fn branch_get_discrete_state(&self, branch: &BranchId) -> Result<Vec<u8>, StorageError> {
let lock = self.branch_discrete_state.read().unwrap();
if let Some(state) = lock.get(&branch) {
Ok(state.to_vec())
} else {
Err(StorageError::NoDiscreteState)
}
}
fn branch_get_tab_info(
&self,
branch: &BranchId,

@ -261,7 +261,7 @@ impl Verifier {
let store_tab_info = AppTabStoreInfo {
repo: Some(repo.store.get_store_repo().clone()),
overlay: Some(format!("v:{}", repo.store.outer_overlay().to_string())),
overlay: Some(format!("v:{}", repo.store.overlay_id.to_string())),
store_type: Some(repo.store.get_store_repo().store_type_for_app()),
has_outer: None, //TODO
inner: None, //TODO
@ -335,6 +335,29 @@ impl Verifier {
}
}
let crdt = &repo.branch(&branch_id)?.crdt;
let discrete = if crdt.is_graph() {
None
} else {
match self
.user_storage
.as_ref()
.unwrap()
.branch_get_discrete_state(&branch_id)
{
Ok(state) => Some(match repo.branch(&branch_id)?.crdt {
BranchCrdt::Automerge(_) => DiscreteState::Automerge(state),
BranchCrdt::YArray(_) => DiscreteState::YArray(state),
BranchCrdt::YMap(_) => DiscreteState::YMap(state),
BranchCrdt::YText(_) => DiscreteState::YText(state),
BranchCrdt::YXml(_) => DiscreteState::YXml(state),
_ => return Err(VerifierError::InvalidBranch),
}),
Err(StorageError::NoDiscreteState) => None,
Err(e) => return Err(e.into()),
}
};
let state = AppState {
heads: branch.current_heads.iter().map(|h| h.id.clone()).collect(),
graph: if results.is_empty() {
@ -344,7 +367,7 @@ impl Verifier {
triples: serde_bare::to_vec(&results).unwrap(),
})
},
discrete: None,
discrete,
files,
};
@ -2424,7 +2447,7 @@ impl Verifier {
//TODO: improve the inner_to_outer insert. (should be done when store is created, not here. should work also for dialogs.)
self.inner_to_outer.insert(
repo.store.overlay_for_read_on_client_protocol(),
repo.store.outer_overlay(),
repo.store.overlay_id,
);
for sub in opened_repo {
Self::branch_was_opened(&self.topics, repo, sub)?;

@ -33,6 +33,7 @@ importers:
specifiers:
'@codemirror/autocomplete': ^6.17.0
'@codemirror/commands': ^6.6.0
'@codemirror/lang-javascript': ^6.2.2
'@codemirror/language': ^6.10.2
'@codemirror/legacy-modes': ^6.4.0
'@codemirror/lint': ^6.8.1
@ -81,9 +82,12 @@ importers:
vite-plugin-svelte-svg: ^2.2.1
vite-plugin-top-level-await: ^1.3.1
vite-plugin-wasm: ^3.2.2
y-codemirror.next: ^0.3.5
yjs: ^13.6.18
dependencies:
'@codemirror/autocomplete': 6.17.0_77urojsfbrmvdrcps23icldzhi
'@codemirror/commands': 6.6.0
'@codemirror/lang-javascript': 6.2.2
'@codemirror/language': 6.10.2
'@codemirror/legacy-modes': 6.4.0
'@codemirror/lint': 6.8.1
@ -106,6 +110,8 @@ importers:
svelte-inview: 4.0.2_svelte@3.59.1
svelte-spa-router: 3.3.0
vite-plugin-top-level-await: 1.3.1_vite@4.3.9
y-codemirror.next: 0.3.5_2derscuhaavtzv2sogf3enfvaa
yjs: 13.6.18
devDependencies:
'@sveltejs/vite-plugin-svelte': 2.4.1_svelte@3.59.1+vite@4.3.9
'@tauri-apps/cli': 2.0.0-alpha.14
@ -195,6 +201,18 @@ packages:
'@lezer/common': 1.2.1
dev: false
/@codemirror/lang-javascript/6.2.2:
resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==}
dependencies:
'@codemirror/autocomplete': 6.17.0_77urojsfbrmvdrcps23icldzhi
'@codemirror/language': 6.10.2
'@codemirror/lint': 6.8.1
'@codemirror/state': 6.4.1
'@codemirror/view': 6.29.1
'@lezer/common': 1.2.1
'@lezer/javascript': 1.4.17
dev: false
/@codemirror/language/6.10.2:
resolution: {integrity: sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==}
dependencies:
@ -718,6 +736,14 @@ packages:
'@lezer/common': 1.2.1
dev: false
/@lezer/javascript/1.4.17:
resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==}
dependencies:
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
'@lezer/lr': 1.4.1
dev: false
/@lezer/lr/1.4.1:
resolution: {integrity: sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==}
dependencies:
@ -1756,6 +1782,10 @@ packages:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/isomorphic.js/0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
dev: false
/jiti/1.18.2:
resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==}
hasBin: true
@ -1766,6 +1796,14 @@ packages:
engines: {node: '>=6'}
dev: true
/lib0/0.2.95:
resolution: {integrity: sha512-St5XGDh5omvNawGkAOa7CFRjxl4xEKLj9DxgT8Nl7rmrD6l2WRTngvmZGhJKRaniROterT0RDVdnwLlU9PiEOg==}
engines: {node: '>=16'}
hasBin: true
dependencies:
isomorphic.js: 0.2.5
dev: false
/lilconfig/2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@ -2770,7 +2808,27 @@ packages:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/y-codemirror.next/0.3.5_2derscuhaavtzv2sogf3enfvaa:
resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==}
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
yjs: ^13.5.6
dependencies:
'@codemirror/state': 6.4.1
'@codemirror/view': 6.29.1
lib0: 0.2.95
yjs: 13.6.18
dev: false
/yaml/2.3.1:
resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==}
engines: {node: '>= 14'}
dev: true
/yjs/13.6.18:
resolution: {integrity: sha512-GBTjO4QCmv2HFKFkYIJl7U77hIB1o22vSCSQD1Ge8ZxWbIbn8AltI4gyXbtL+g5/GJep67HCMq3Y5AmNwDSyEg==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
dependencies:
lib0: 0.2.95
dev: false

Loading…
Cancel
Save