@homeostate/crdt-automerge 0.1.1 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @homeostate/crdt-automerge
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 30abf27: Remove draft release warnings, update docs links
8
+ - Updated dependencies [30abf27]
9
+ - @homeostate/core@0.1.2
10
+
3
11
  ## 0.1.1
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # @homeostate/crdt-automerge
2
2
 
3
- > **Draft / placeholder release.** `0.0.0` reserves the name while the API is still
4
- > being designed. Nothing here is stable — do not depend on it yet.
5
-
6
3
  [Automerge](https://automerge.org) backend for
7
4
  [`@homeostate/core`](https://github.com/mixedrays/homeostate/tree/main/packages/core).
8
5
  It maps the synced state onto one key of an Automerge document (nested objects become
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeostate/crdt-automerge",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Automerge CRDT backend for @homeostate/core",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,7 @@
9
9
  "url": "git+https://github.com/mixedrays/homeostate.git",
10
10
  "directory": "packages/crdt-automerge"
11
11
  },
12
- "homepage": "https://github.com/mixedrays/homeostate#readme",
12
+ "homepage": "https://homeostate.pages.dev",
13
13
  "bugs": {
14
14
  "url": "https://github.com/mixedrays/homeostate/issues"
15
15
  },
@@ -30,6 +30,8 @@
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
+ "src",
34
+ "!src/**/__tests__",
33
35
  "README.md",
34
36
  "LICENSE",
35
37
  "THIRD_PARTY_NOTICES.md",
@@ -39,7 +41,7 @@
39
41
  "access": "public"
40
42
  },
41
43
  "dependencies": {
42
- "@homeostate/core": "^0.1.1"
44
+ "@homeostate/core": "^0.1.2"
43
45
  },
44
46
  "peerDependencies": {
45
47
  "@automerge/automerge": "^3.0.0"
@@ -0,0 +1,37 @@
1
+ import type { CrdtBackend, Unsubscribe } from "@homeostate/core";
2
+ import type { AutomergeHandle } from "./handle.js";
3
+ import { applyChanges, diff, toJson, type Container } from "./patching.js";
4
+ import { createSnapshot } from "./snapshot.js";
5
+
6
+ export const createAutomergeBackend = <T extends Container>(
7
+ handle: AutomergeHandle<T>,
8
+ name: string,
9
+ ): CrdtBackend => {
10
+ const snapshot = createSnapshot();
11
+ let writing = false;
12
+
13
+ const current = (): unknown => (handle.doc() as Container)[name];
14
+
15
+ return {
16
+ read: () => snapshot(current()) ?? {},
17
+
18
+ write: (next) => {
19
+ const changes = diff(current(), next);
20
+ if (changes?.length === 0) return;
21
+ writing = true;
22
+ try {
23
+ handle.change((doc) => {
24
+ if (changes === null) (doc as Container)[name] = toJson(next);
25
+ else applyChanges(doc as Container, [name], changes);
26
+ });
27
+ } finally {
28
+ writing = false;
29
+ }
30
+ },
31
+
32
+ subscribe: (onRemoteChange): Unsubscribe =>
33
+ handle.subscribe(() => {
34
+ if (!writing) onRemoteChange();
35
+ }),
36
+ };
37
+ };
package/src/handle.ts ADDED
@@ -0,0 +1,51 @@
1
+ import * as A from "@automerge/automerge";
2
+ import type { Unsubscribe } from "@homeostate/core";
3
+
4
+ export interface AutomergeHandleEvent<T> {
5
+ doc: A.Doc<T>;
6
+ local: boolean;
7
+ }
8
+
9
+ export interface AutomergeHandle<T = Record<string, unknown>> {
10
+ doc: () => A.Doc<T>;
11
+ change: (fn: A.ChangeFn<T>) => void;
12
+ update: (fn: (doc: A.Doc<T>) => A.Doc<T>) => void;
13
+ subscribe: (
14
+ listener: (event: AutomergeHandleEvent<T>) => void,
15
+ ) => Unsubscribe;
16
+ }
17
+
18
+ const sameHeads = (a: A.Heads, b: A.Heads): boolean =>
19
+ a.length === b.length && a.every((hash, i) => hash === b[i]);
20
+
21
+ export const createAutomergeHandle = <T = Record<string, unknown>>(
22
+ initial: A.Doc<T> = A.init<T>(),
23
+ ): AutomergeHandle<T> => {
24
+ let current = initial;
25
+ const listeners = new Set<(event: AutomergeHandleEvent<T>) => void>();
26
+
27
+ const replace = (
28
+ operation: (doc: A.Doc<T>) => A.Doc<T>,
29
+ local: boolean,
30
+ ): void => {
31
+ const before = A.getHeads(current);
32
+ const next = operation(current);
33
+ if (next === current) return;
34
+ current = next;
35
+ if (sameHeads(before, A.getHeads(next))) return;
36
+ const event = { doc: next, local };
37
+ listeners.forEach((listener) => listener(event));
38
+ };
39
+
40
+ return {
41
+ doc: () => current,
42
+ change: (fn) => replace((doc) => A.change(doc, fn), true),
43
+ update: (fn) => replace(fn, false),
44
+ subscribe: (listener): Unsubscribe => {
45
+ listeners.add(listener);
46
+ return () => {
47
+ listeners.delete(listener);
48
+ };
49
+ },
50
+ };
51
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { createAutomergeBackend } from "./automerge-backend.js";
2
+ export { createAutomergeHandle } from "./handle.js";
3
+ export type { AutomergeHandle, AutomergeHandleEvent } from "./handle.js";
@@ -0,0 +1,105 @@
1
+ import * as A from "@automerge/automerge";
2
+ import { ChangeType, getChanges, type Change } from "@homeostate/core";
3
+
4
+ export type Container = Record<string, unknown>;
5
+
6
+ const isRecord = (value: unknown): value is Container =>
7
+ value !== null && typeof value === "object" && !Array.isArray(value);
8
+
9
+ export const toJson = (value: unknown): unknown => {
10
+ if (Array.isArray(value))
11
+ return value.map((item) => (item === undefined ? null : toJson(item)));
12
+ if (!isRecord(value)) return value;
13
+ const result: Container = {};
14
+ for (const [key, item] of Object.entries(value))
15
+ if (item !== undefined) result[key] = toJson(item);
16
+ return result;
17
+ };
18
+
19
+ export const diff = (current: unknown, next: unknown): Change[] | null => {
20
+ if (!isRecord(next)) return [];
21
+ return isRecord(current) ? getChanges(current, next) : null;
22
+ };
23
+
24
+ export const applyChanges = (
25
+ doc: Container,
26
+ path: A.Prop[],
27
+ changes: Change[],
28
+ ): void => {
29
+ const container = path.reduce<unknown>(
30
+ (value, prop) => (value as Container)[prop],
31
+ doc,
32
+ );
33
+ for (const [type, key, value] of changes) {
34
+ if (typeof container === "string")
35
+ applyToText(doc, path, type, key as number, value);
36
+ else if (Array.isArray(container))
37
+ applyToList(doc, path, container, type, key as number, value);
38
+ else
39
+ applyToMap(doc, path, container as Container, type, key as string, value);
40
+ }
41
+ };
42
+
43
+ const applyToMap = (
44
+ doc: Container,
45
+ path: A.Prop[],
46
+ map: Container,
47
+ type: ChangeType,
48
+ key: string,
49
+ value: unknown,
50
+ ): void => {
51
+ switch (type) {
52
+ case ChangeType.INSERT:
53
+ case ChangeType.UPDATE:
54
+ if (value !== undefined) map[key] = toJson(value);
55
+ else if (key in map) delete map[key];
56
+ break;
57
+
58
+ case ChangeType.DELETE:
59
+ delete map[key];
60
+ break;
61
+
62
+ case ChangeType.PENDING:
63
+ applyChanges(doc, [...path, key], value as Change[]);
64
+ break;
65
+ }
66
+ };
67
+
68
+ const applyToList = (
69
+ doc: Container,
70
+ path: A.Prop[],
71
+ list: unknown[],
72
+ type: ChangeType,
73
+ index: number,
74
+ value: unknown,
75
+ ): void => {
76
+ switch (type) {
77
+ case ChangeType.INSERT:
78
+ A.insertAt(list, index, toJson(value));
79
+ break;
80
+
81
+ case ChangeType.UPDATE:
82
+ list[index] = toJson(value);
83
+ break;
84
+
85
+ case ChangeType.DELETE:
86
+ A.deleteAt(list, index, 1);
87
+ break;
88
+
89
+ case ChangeType.PENDING:
90
+ applyChanges(doc, [...path, index], value as Change[]);
91
+ break;
92
+ }
93
+ };
94
+
95
+ const applyToText = (
96
+ doc: Container,
97
+ path: A.Prop[],
98
+ type: ChangeType,
99
+ index: number,
100
+ value: unknown,
101
+ ): void => {
102
+ if (type === ChangeType.INSERT)
103
+ A.splice(doc, path, index, 0, value as string);
104
+ else if (type === ChangeType.DELETE) A.splice(doc, path, index, 1);
105
+ };
@@ -0,0 +1,18 @@
1
+ export const createSnapshot = (): ((value: unknown) => unknown) => {
2
+ const copies = new WeakMap<object, unknown>();
3
+
4
+ const copy = (value: unknown): unknown => {
5
+ if (value === null || typeof value !== "object") return value;
6
+ const cached = copies.get(value);
7
+ if (cached !== undefined) return cached;
8
+ const result = Array.isArray(value)
9
+ ? value.map(copy)
10
+ : Object.fromEntries(
11
+ Object.entries(value).map(([key, item]) => [key, copy(item)]),
12
+ );
13
+ copies.set(value, result);
14
+ return result;
15
+ };
16
+
17
+ return copy;
18
+ };