bireactive 0.3.2 → 0.3.3
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.
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
import type { DocHandle } from "@automerge/automerge-repo";
|
|
2
2
|
import { type Cell, type Writable } from "../core/cell.js";
|
|
3
3
|
import { type Store } from "../core/store.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
import { type By } from "./reconcile.js";
|
|
5
|
+
/** Bridge options shared by every `connect*` entry. */
|
|
6
|
+
export interface DocOptions {
|
|
7
|
+
/** Identity key for list elements, enabling keyed reconciliation (see `reconcile`). */
|
|
8
|
+
by?: By;
|
|
9
|
+
}
|
|
10
|
+
/** Lifecycle shared by every bridge: retarget the doc, detach both directions. */
|
|
11
|
+
export interface DocLifecycle<T> {
|
|
10
12
|
/** Point the same cell at a different doc, keeping every bound lens/view alive. */
|
|
11
13
|
retarget: (handle: DocHandle<T>) => void;
|
|
12
14
|
/** Detach both directions (call from `disconnectedCallback`). */
|
|
13
15
|
dispose: () => void;
|
|
14
16
|
}
|
|
17
|
+
/** Doc bridged to a writable cell; writes (direct or via a lens) flow to the CRDT. */
|
|
18
|
+
export interface CellBridge<T> extends DocLifecycle<T> {
|
|
19
|
+
cell: Writable<Cell<T>>;
|
|
20
|
+
}
|
|
21
|
+
/** Doc bridged to a deep `store` — `bridge.store.a.b.value = x` commits to the doc. */
|
|
22
|
+
export interface StoreBridge<T> extends DocLifecycle<T> {
|
|
23
|
+
store: Store<T>;
|
|
24
|
+
}
|
|
25
|
+
/** Doc bridged to both a cell and a deep store. */
|
|
26
|
+
export interface DocBridge<T> extends CellBridge<T>, StoreBridge<T> {
|
|
27
|
+
}
|
|
28
|
+
/** Connect a `DocHandle` to a reactive cell, syncing both ways. */
|
|
29
|
+
export declare function connectCell<T extends object>(handle: DocHandle<T>, opts?: DocOptions): CellBridge<T>;
|
|
30
|
+
/** Connect a `DocHandle` to a deep `store`, syncing both ways. */
|
|
31
|
+
export declare function connectStore<T extends object>(handle: DocHandle<T>, opts?: DocOptions): StoreBridge<T>;
|
|
15
32
|
/** Connect a `DocHandle` to a reactive cell + store, syncing both ways. */
|
|
16
|
-
export declare function connectDoc<T extends object>(handle: DocHandle<T
|
|
17
|
-
/** Doc as a writable cell (page-lifetime; use {@link connectDoc} when you need disposal). */
|
|
18
|
-
export declare function docCell<T extends object>(handle: DocHandle<T>): Writable<Cell<T>>;
|
|
19
|
-
/** Doc as a deep store (page-lifetime; use {@link connectDoc} when you need disposal). */
|
|
20
|
-
export declare function docStore<T extends object>(handle: DocHandle<T>): Store<T>;
|
|
33
|
+
export declare function connectDoc<T extends object>(handle: DocHandle<T>, opts?: DocOptions): DocBridge<T>;
|
|
@@ -39,42 +39,48 @@ function deepEqual(a, b) {
|
|
|
39
39
|
return true;
|
|
40
40
|
}
|
|
41
41
|
/** Wire an existing cell to a handle in both directions; returns an unbind. */
|
|
42
|
-
function bind(c, handle) {
|
|
42
|
+
function bind(c, handle, by) {
|
|
43
43
|
const onChange = () => {
|
|
44
44
|
c.value = structuredClone(handle.doc());
|
|
45
45
|
};
|
|
46
46
|
handle.on("change", onChange);
|
|
47
47
|
const stop = effect(() => {
|
|
48
48
|
const next = c.value;
|
|
49
|
-
handle.change((d) => reconcile(d, next));
|
|
49
|
+
handle.change((d) => reconcile(d, next, by));
|
|
50
50
|
});
|
|
51
51
|
return () => {
|
|
52
52
|
stop();
|
|
53
53
|
handle.off("change", onChange);
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
56
|
+
/** Core: a doc-backed cell plus lifecycle. The cell projections layer on top. */
|
|
57
|
+
function connect(handle, opts) {
|
|
58
|
+
const by = opts?.by;
|
|
58
59
|
const c = cell(structuredClone(handle.doc()), { equals: deepEqual, name: "doc" });
|
|
59
|
-
let unbind = bind(c, handle);
|
|
60
|
+
let unbind = bind(c, handle, by);
|
|
60
61
|
return {
|
|
61
62
|
cell: c,
|
|
62
|
-
store: store(c),
|
|
63
63
|
retarget: next => {
|
|
64
64
|
unbind();
|
|
65
65
|
// Seed the cell from the new doc *before* re-binding, so the cell→doc
|
|
66
66
|
// effect doesn't push the old value into the freshly targeted doc.
|
|
67
67
|
c.value = structuredClone(next.doc());
|
|
68
|
-
unbind = bind(c, next);
|
|
68
|
+
unbind = bind(c, next, by);
|
|
69
69
|
},
|
|
70
70
|
dispose: () => unbind(),
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
-
/**
|
|
74
|
-
export function
|
|
75
|
-
return
|
|
73
|
+
/** Connect a `DocHandle` to a reactive cell, syncing both ways. */
|
|
74
|
+
export function connectCell(handle, opts) {
|
|
75
|
+
return connect(handle, opts);
|
|
76
|
+
}
|
|
77
|
+
/** Connect a `DocHandle` to a deep `store`, syncing both ways. */
|
|
78
|
+
export function connectStore(handle, opts) {
|
|
79
|
+
const { cell: c, retarget, dispose } = connect(handle, opts);
|
|
80
|
+
return { store: store(c), retarget, dispose };
|
|
76
81
|
}
|
|
77
|
-
/**
|
|
78
|
-
export function
|
|
79
|
-
|
|
82
|
+
/** Connect a `DocHandle` to a reactive cell + store, syncing both ways. */
|
|
83
|
+
export function connectDoc(handle, opts) {
|
|
84
|
+
const b = connect(handle, opts);
|
|
85
|
+
return { ...b, store: store(b.cell) };
|
|
80
86
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export type { DocBridge } from "./doc-cell.js";
|
|
2
|
-
export {
|
|
1
|
+
export type { CellBridge, DocBridge, DocLifecycle, DocOptions, StoreBridge } from "./doc-cell.js";
|
|
2
|
+
export { connectCell, connectDoc, connectStore } from "./doc-cell.js";
|
|
3
|
+
export type { By } from "./reconcile.js";
|
|
3
4
|
export { reconcile } from "./reconcile.js";
|
package/dist/automerge/index.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// @bireactive/automerge — view an Automerge CRDT through the reactive graph.
|
|
2
2
|
//
|
|
3
3
|
// `connectDoc(handle)` turns a `DocHandle` into a `Writable<Cell<T>>` plus a deep
|
|
4
|
-
// `store`, synced both ways
|
|
5
|
-
//
|
|
6
|
-
// is the apex, every view is a leg.
|
|
7
|
-
// writes merge-friendly; it's exported
|
|
4
|
+
// `store`, synced both ways; `connectCell`/`connectStore` give just one projection.
|
|
5
|
+
// Lens/`store` views off that one cell give you many schemas over a single shared
|
|
6
|
+
// doc with no privileged "primary" — the CRDT is the apex, every view is a leg.
|
|
7
|
+
// `reconcile` is the doc-side diff that keeps writes merge-friendly; it's exported
|
|
8
|
+
// for custom bridges.
|
|
8
9
|
//
|
|
9
10
|
// Automerge is an optional peer dependency: import this entry only when you've
|
|
10
11
|
// installed `@automerge/automerge-repo`.
|
|
11
|
-
export {
|
|
12
|
+
export { connectCell, connectDoc, connectStore } from "./doc-cell.js";
|
|
12
13
|
export { reconcile } from "./reconcile.js";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
type Any = any;
|
|
2
|
+
/** Stable identity key for a list element; return a primitive. `undefined` (or a
|
|
3
|
+
* collision) on any element makes that list fall back to positional. */
|
|
4
|
+
export type By = (element: unknown) => unknown;
|
|
2
5
|
/** Minimally mutate the Automerge node `target` (inside `handle.change`) to equal
|
|
3
|
-
* the plain value `next`. */
|
|
4
|
-
export declare function reconcile(target: Any, next: Any): void;
|
|
6
|
+
* the plain value `next`. Pass `by` for identity-keyed list reconciliation. */
|
|
7
|
+
export declare function reconcile(target: Any, next: Any, by?: By): void;
|
|
5
8
|
export {};
|
|
@@ -9,38 +9,96 @@
|
|
|
9
9
|
// `updateText` for strings (char-level), in-place splices for lists, recursive
|
|
10
10
|
// descent for objects, scalar sets for the rest.
|
|
11
11
|
//
|
|
12
|
-
// List handling is
|
|
13
|
-
//
|
|
14
|
-
// insert
|
|
15
|
-
//
|
|
12
|
+
// List handling is positional by default: element-wise in place, with a tail
|
|
13
|
+
// push/truncate. Correct for edits/appends/truncations, but a reorder or mid
|
|
14
|
+
// insert rewrites every shifted slot's scalars — merge-hostile. Pass `by` for
|
|
15
|
+
// identity-keyed reconciliation (mirrors the `eachBy` lens's `by`): a longest
|
|
16
|
+
// common subsequence keeps shared elements in place and emits minimal keyed
|
|
17
|
+
// splices/inserts for the rest, so reorders and mid-inserts merge cleanly.
|
|
16
18
|
import { updateText } from "@automerge/automerge-repo";
|
|
17
19
|
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
20
|
/** Minimally mutate the Automerge node `target` (inside `handle.change`) to equal
|
|
19
|
-
* the plain value `next`. */
|
|
20
|
-
export function reconcile(target, next) {
|
|
21
|
+
* the plain value `next`. Pass `by` for identity-keyed list reconciliation. */
|
|
22
|
+
export function reconcile(target, next, by) {
|
|
21
23
|
if (Array.isArray(next) && Array.isArray(target))
|
|
22
|
-
reconcileList(target, next);
|
|
24
|
+
reconcileList(target, next, by);
|
|
23
25
|
else
|
|
24
|
-
reconcileObject(target, next);
|
|
26
|
+
reconcileObject(target, next, by);
|
|
25
27
|
}
|
|
26
|
-
function reconcileObject(target, next) {
|
|
28
|
+
function reconcileObject(target, next, by) {
|
|
27
29
|
for (const k of Object.keys(target))
|
|
28
30
|
if (!(k in next))
|
|
29
31
|
delete target[k];
|
|
30
32
|
for (const k of Object.keys(next))
|
|
31
|
-
setKey(target, k, target[k], next[k], false);
|
|
33
|
+
setKey(target, k, target[k], next[k], false, by);
|
|
32
34
|
}
|
|
33
|
-
function reconcileList(target, next) {
|
|
35
|
+
function reconcileList(target, next, by) {
|
|
36
|
+
if (by !== undefined && reconcileKeyed(target, next, by))
|
|
37
|
+
return;
|
|
34
38
|
const shared = Math.min(target.length, next.length);
|
|
35
39
|
for (let i = 0; i < shared; i++)
|
|
36
|
-
setKey(target, i, target[i], next[i], true);
|
|
40
|
+
setKey(target, i, target[i], next[i], true, by);
|
|
37
41
|
if (next.length < target.length)
|
|
38
42
|
target.splice(next.length);
|
|
39
43
|
else
|
|
40
44
|
for (let i = target.length; i < next.length; i++)
|
|
41
45
|
target.push(next[i]);
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
/** Keyed list reconcile via LCS. Returns false (→ positional fallback) when keys
|
|
48
|
+
* aren't total + unique on either side. */
|
|
49
|
+
function reconcileKeyed(target, next, by) {
|
|
50
|
+
const tKeys = target.map(by);
|
|
51
|
+
const nKeys = next.map(by);
|
|
52
|
+
if (!totalUnique(tKeys) || !totalUnique(nKeys))
|
|
53
|
+
return false;
|
|
54
|
+
const keep = lcs(tKeys, nKeys);
|
|
55
|
+
let i = 0; // cursor into `target`, which mutates as we splice
|
|
56
|
+
for (let n = 0; n < next.length; n++) {
|
|
57
|
+
if (keep.has(nKeys[n])) {
|
|
58
|
+
while (i < target.length && !keep.has(by(target[i])))
|
|
59
|
+
target.splice(i, 1);
|
|
60
|
+
setKey(target, i, target[i], next[n], true, by); // same identity → merge edits
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
target.splice(i, 0, next[n]); // insert (new key, or a moved element re-placed)
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (i < target.length)
|
|
69
|
+
target.splice(i);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function totalUnique(keys) {
|
|
73
|
+
if (keys.some(k => k === undefined))
|
|
74
|
+
return false;
|
|
75
|
+
return new Set(keys).size === keys.length;
|
|
76
|
+
}
|
|
77
|
+
/** Keys of the longest common subsequence of `a` and `b` (`===` on keys). */
|
|
78
|
+
function lcs(a, b) {
|
|
79
|
+
const n = a.length;
|
|
80
|
+
const m = b.length;
|
|
81
|
+
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
82
|
+
for (let i = n - 1; i >= 0; i--)
|
|
83
|
+
for (let j = m - 1; j >= 0; j--)
|
|
84
|
+
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
85
|
+
const keep = new Set();
|
|
86
|
+
let i = 0;
|
|
87
|
+
let j = 0;
|
|
88
|
+
while (i < n && j < m) {
|
|
89
|
+
if (a[i] === b[j]) {
|
|
90
|
+
keep.add(a[i]);
|
|
91
|
+
i++;
|
|
92
|
+
j++;
|
|
93
|
+
}
|
|
94
|
+
else if (dp[i + 1][j] >= dp[i][j + 1])
|
|
95
|
+
i++;
|
|
96
|
+
else
|
|
97
|
+
j++;
|
|
98
|
+
}
|
|
99
|
+
return keep;
|
|
100
|
+
}
|
|
101
|
+
function setKey(parent, key, a, b, inList, by) {
|
|
44
102
|
if (typeof b === "string" && typeof a === "string") {
|
|
45
103
|
// Char-level merge for object text fields; list string elements just assign
|
|
46
104
|
// (path-relative updateText targets a keyed field, not an array slot).
|
|
@@ -52,10 +110,10 @@ function setKey(parent, key, a, b, inList) {
|
|
|
52
110
|
}
|
|
53
111
|
}
|
|
54
112
|
else if (Array.isArray(b) && Array.isArray(a)) {
|
|
55
|
-
reconcileList(a, b);
|
|
113
|
+
reconcileList(a, b, by);
|
|
56
114
|
}
|
|
57
115
|
else if (isPlainObject(b) && isPlainObject(a)) {
|
|
58
|
-
reconcileObject(a, b);
|
|
116
|
+
reconcileObject(a, b, by);
|
|
59
117
|
}
|
|
60
118
|
else if (a !== b) {
|
|
61
119
|
parent[key] = b;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bireactive",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Bi-directional reactive programming.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -53,13 +53,21 @@
|
|
|
53
53
|
"postversion": "npm publish && git push --follow-tags"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@automerge/automerge-repo": "^2.6.0-subduction.34",
|
|
57
|
-
"@automerge/automerge-repo-network-broadcastchannel": "^2.6.0-subduction.34",
|
|
58
|
-
"@automerge/automerge-repo-storage-indexeddb": "^2.6.0-subduction.34",
|
|
59
56
|
"prism-esm": "^1.29.0-fix.6",
|
|
60
57
|
"temml": "^0.13.3"
|
|
61
58
|
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@automerge/automerge-repo": "^2.6.0-subduction.34"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"@automerge/automerge-repo": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
62
67
|
"devDependencies": {
|
|
68
|
+
"@automerge/automerge-repo": "^2.6.0-subduction.34",
|
|
69
|
+
"@automerge/automerge-repo-network-broadcastchannel": "^2.6.0-subduction.34",
|
|
70
|
+
"@automerge/automerge-repo-storage-indexeddb": "^2.6.0-subduction.34",
|
|
63
71
|
"@biomejs/biome": "2.4.15",
|
|
64
72
|
"@preact/signals-core": "^1.14.2",
|
|
65
73
|
"@types/node": "^22.0.0",
|