@strata-sync/mobx 0.1.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/AGENTS.md ADDED
@@ -0,0 +1,23 @@
1
+ # @strata-sync/mobx
2
+
3
+ MobX reactivity adapter for the sync system.
4
+
5
+ ## Commands
6
+
7
+ - `npm run build` — compile TypeScript (`tsc`)
8
+ - `npm run dev` — watch mode (`tsc --watch`)
9
+ - `npm run test` — run tests (`vitest`)
10
+ - `npm run lint` — lint with Biome
11
+ - `npm run check-types` — type check without emitting
12
+
13
+ ## Gotchas
14
+
15
+ - `mobx` ^6.0.0 is a peer dependency — the consuming app must install it
16
+ - This package implements the reactivity adapter interface defined in `@strata-sync/core` — changes to the interface require updates here
17
+ - MobX observable properties are set up during model hydration — accessing properties before hydration will not trigger reactions
18
+
19
+ ## Conventions
20
+
21
+ - Implement the `ReactivityAdapter` interface from sync-core — do not create a custom interface
22
+ - Use MobX `makeObservable` / `makeAutoObservable` patterns, not legacy decorators
23
+ - Computed values should derive from observable model properties only
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @strata-sync/mobx
2
+
3
+ MobX reactivity adapter for the Strata Sync.
4
+
5
+ ## Overview
6
+
7
+ sync-mobx implements the reactivity adapter interface defined in `@strata-sync/core` using MobX observables:
8
+
9
+ - **Observable model instances** — model properties become MobX observables
10
+ - **Computed values** — derived state from observable model properties
11
+ - **Reaction-based updates** — automatic re-rendering when synced data changes
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @strata-sync/mobx
17
+ ```
18
+
19
+ Peer dependency: `mobx` ^6.0.0
20
+
21
+ ## Usage
22
+
23
+ Register the MobX adapter when initializing the sync client:
24
+
25
+ ```typescript
26
+ import { createMobxAdapter } from "@strata-sync/mobx";
27
+
28
+ const adapter = createMobxAdapter();
29
+
30
+ // Pass to SyncClient configuration
31
+ const client = new SyncClient({
32
+ reactivityAdapter: adapter,
33
+ });
34
+ ```
35
+
36
+ The adapter makes model instances observable, so MobX `observer()` components and `autorun` / `reaction` will automatically track and respond to sync updates.
37
+
38
+ ## How It Works
39
+
40
+ 1. When models are hydrated from sync deltas, the adapter wraps properties with MobX observables
41
+ 2. React components wrapped with `observer()` automatically re-render when observed properties change
42
+ 3. Computed values can derive state from multiple observable model properties
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@strata-sync/mobx",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsc --watch",
19
+ "lint": "biome lint .",
20
+ "check-types": "tsc --noEmit",
21
+ "test": "vitest"
22
+ },
23
+ "dependencies": {
24
+ "@strata-sync/core": "*",
25
+ "mobx": "^6.13.0"
26
+ },
27
+ "devDependencies": {
28
+ "@biomejs/biome": "^2.3.12",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.0.18"
31
+ },
32
+ "peerDependencies": {
33
+ "mobx": "^6.0.0"
34
+ }
35
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,212 @@
1
+ import type {
2
+ DisposeFn,
3
+ ObservableArray,
4
+ ObservableBox,
5
+ ObservableMap,
6
+ ObservableOptions,
7
+ ReactionOptions,
8
+ ReactivityAdapter,
9
+ } from "@strata-sync/core";
10
+ import {
11
+ computed,
12
+ makeObservable,
13
+ observable,
14
+ reaction,
15
+ runInAction,
16
+ } from "mobx";
17
+
18
+ class MobXBox<T> implements ObservableBox<T> {
19
+ value: T;
20
+
21
+ constructor(initialValue: T) {
22
+ this.value = initialValue;
23
+ makeObservable(this, {
24
+ value: observable,
25
+ });
26
+ }
27
+
28
+ get(): T {
29
+ return this.value;
30
+ }
31
+
32
+ set(value: T): void {
33
+ runInAction(() => {
34
+ this.value = value;
35
+ });
36
+ }
37
+ }
38
+
39
+ class MobXMap<K, V> implements ObservableMap<K, V> {
40
+ private readonly map: Map<K, V>;
41
+
42
+ constructor(entries?: Iterable<[K, V]>) {
43
+ this.map = observable.map<K, V>(entries ? new Map(entries) : undefined);
44
+ }
45
+
46
+ get(key: K): V | undefined {
47
+ return this.map.get(key);
48
+ }
49
+
50
+ set(key: K, value: V): void {
51
+ runInAction(() => {
52
+ this.map.set(key, value);
53
+ });
54
+ }
55
+
56
+ has(key: K): boolean {
57
+ return this.map.has(key);
58
+ }
59
+
60
+ delete(key: K): boolean {
61
+ return runInAction(() => this.map.delete(key));
62
+ }
63
+
64
+ clear(): void {
65
+ runInAction(() => {
66
+ this.map.clear();
67
+ });
68
+ }
69
+
70
+ keys(): IterableIterator<K> {
71
+ return this.map.keys();
72
+ }
73
+
74
+ values(): IterableIterator<V> {
75
+ return this.map.values();
76
+ }
77
+
78
+ entries(): IterableIterator<[K, V]> {
79
+ return this.map.entries();
80
+ }
81
+
82
+ get size(): number {
83
+ return this.map.size;
84
+ }
85
+
86
+ forEach(callback: (value: V, key: K) => void): void {
87
+ this.map.forEach(callback);
88
+ }
89
+ }
90
+
91
+ class MobXArray<T> implements ObservableArray<T> {
92
+ private readonly array: T[];
93
+
94
+ constructor(items?: T[]) {
95
+ this.array = observable.array<T>(items ?? []);
96
+ }
97
+
98
+ get(index: number): T | undefined {
99
+ return this.array[index];
100
+ }
101
+
102
+ toArray(): T[] {
103
+ return [...this.array];
104
+ }
105
+
106
+ push(...items: T[]): number {
107
+ return runInAction(() => this.array.push(...items));
108
+ }
109
+
110
+ pop(): T | undefined {
111
+ return runInAction(() => this.array.pop());
112
+ }
113
+
114
+ remove(predicate: (item: T) => boolean): T[] {
115
+ return runInAction(() => {
116
+ const removed: T[] = [];
117
+ for (let i = this.array.length - 1; i >= 0; i--) {
118
+ const item = this.array[i];
119
+ if (item !== undefined && predicate(item)) {
120
+ removed.push(item);
121
+ this.array.splice(i, 1);
122
+ }
123
+ }
124
+ return removed;
125
+ });
126
+ }
127
+
128
+ replace(items: T[]): void {
129
+ runInAction(() => {
130
+ this.array.length = 0;
131
+ this.array.push(...items);
132
+ });
133
+ }
134
+
135
+ clear(): void {
136
+ runInAction(() => {
137
+ this.array.length = 0;
138
+ });
139
+ }
140
+
141
+ find(predicate: (item: T) => boolean): T | undefined {
142
+ return this.array.find(predicate);
143
+ }
144
+
145
+ filter(predicate: (item: T) => boolean): T[] {
146
+ return this.array.filter(predicate);
147
+ }
148
+
149
+ map<U>(mapper: (item: T) => U): U[] {
150
+ return this.array.map(mapper);
151
+ }
152
+
153
+ get length(): number {
154
+ return this.array.length;
155
+ }
156
+
157
+ [Symbol.iterator](): Iterator<T> {
158
+ return this.array[Symbol.iterator]();
159
+ }
160
+ }
161
+
162
+ export const mobxReactivityAdapter: ReactivityAdapter = {
163
+ createBox<T>(
164
+ initialValue: T,
165
+ _options?: ObservableOptions
166
+ ): ObservableBox<T> {
167
+ return new MobXBox(initialValue);
168
+ },
169
+
170
+ createMap<K, V>(
171
+ entries?: Iterable<[K, V]>,
172
+ _options?: ObservableOptions
173
+ ): ObservableMap<K, V> {
174
+ return new MobXMap(entries);
175
+ },
176
+
177
+ createArray<T>(
178
+ items?: T[],
179
+ _options?: ObservableOptions
180
+ ): ObservableArray<T> {
181
+ return new MobXArray(items);
182
+ },
183
+
184
+ makeObservable<T extends object>(target: T, _options?: ObservableOptions): T {
185
+ return observable(target);
186
+ },
187
+
188
+ batch(fn) {
189
+ runInAction(fn);
190
+ },
191
+
192
+ runInAction<T>(fn: () => T): T {
193
+ return runInAction(fn);
194
+ },
195
+
196
+ reaction<T>(
197
+ expression: () => T,
198
+ effect: (value: T) => void,
199
+ options?: ReactionOptions
200
+ ): DisposeFn {
201
+ return reaction(expression, effect, options);
202
+ },
203
+
204
+ computed<T>(getter: () => T, _options?: ObservableOptions): { get(): T } {
205
+ const c = computed(getter);
206
+ return { get: () => c.get() };
207
+ },
208
+ };
209
+
210
+ export function createMobXReactivity(): ReactivityAdapter {
211
+ return mobxReactivityAdapter;
212
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type { Hydrated, LazyReference } from "@strata-sync/core";
2
+ // biome-ignore lint/performance/noBarrelFile: package entry point
3
+ export {
4
+ BackReference,
5
+ CachedPromise,
6
+ ClientModel,
7
+ Collection,
8
+ EphemeralProperty,
9
+ LazyCollection,
10
+ ManyToOne,
11
+ Model,
12
+ makeObservableProperty,
13
+ makeReferenceModelProperty,
14
+ OneToMany,
15
+ Property,
16
+ Reference,
17
+ ReferenceArray,
18
+ ReferenceCollection,
19
+ resolvePromise,
20
+ } from "@strata-sync/core";
21
+ export { createMobXReactivity, mobxReactivityAdapter } from "./adapter";
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMobXReactivity, mobxReactivityAdapter } from "../src/index";
3
+
4
+ describe("mobx reactivity adapter", () => {
5
+ it("returns the shared adapter instance", () => {
6
+ expect(createMobXReactivity()).toBe(mobxReactivityAdapter);
7
+ });
8
+
9
+ it("creates observable boxes that notify reactions", () => {
10
+ const adapter = createMobXReactivity();
11
+ const box = adapter.createBox(1);
12
+ const values: number[] = [];
13
+
14
+ const dispose = adapter.reaction(
15
+ () => box.get(),
16
+ (value) => values.push(value),
17
+ { fireImmediately: true }
18
+ );
19
+
20
+ box.set(2);
21
+ box.set(3);
22
+
23
+ expect(values).toEqual([1, 2, 3]);
24
+ dispose();
25
+ });
26
+
27
+ it("batches updates with action", () => {
28
+ const adapter = createMobXReactivity();
29
+ const box = adapter.createBox(0);
30
+ const values: number[] = [];
31
+
32
+ const dispose = adapter.reaction(
33
+ () => box.get(),
34
+ (value) => values.push(value)
35
+ );
36
+
37
+ adapter.batch(() => {
38
+ box.set(1);
39
+ box.set(2);
40
+ });
41
+
42
+ expect(values).toEqual([2]);
43
+ dispose();
44
+ });
45
+
46
+ it("creates observable maps that react to key changes", () => {
47
+ const adapter = createMobXReactivity();
48
+ const map = adapter.createMap([["a", 1]]);
49
+ const values: Array<number | undefined> = [];
50
+
51
+ const dispose = adapter.reaction(
52
+ () => map.get("a"),
53
+ (value) => values.push(value),
54
+ { fireImmediately: true }
55
+ );
56
+
57
+ map.set("a", 2);
58
+
59
+ expect(values).toEqual([1, 2]);
60
+ dispose();
61
+ });
62
+
63
+ it("creates observable arrays that track length changes", () => {
64
+ const adapter = createMobXReactivity();
65
+ const array = adapter.createArray([1, 2, 3]);
66
+ const lengths: number[] = [];
67
+
68
+ const dispose = adapter.reaction(
69
+ () => array.length,
70
+ (value) => lengths.push(value),
71
+ { fireImmediately: true }
72
+ );
73
+
74
+ array.push(4);
75
+ array.remove((value) => value % 2 === 0);
76
+
77
+ expect(array.toArray()).toEqual([1, 3]);
78
+ expect(lengths).toEqual([3, 4, 2]);
79
+ dispose();
80
+ });
81
+
82
+ it("supports computed values derived from observables", () => {
83
+ const adapter = createMobXReactivity();
84
+ const box = adapter.createBox(2);
85
+ const doubled = adapter.computed(() => box.get() * 2);
86
+ const values: number[] = [];
87
+
88
+ const dispose = adapter.reaction(
89
+ () => doubled.get(),
90
+ (value) => values.push(value),
91
+ { fireImmediately: true }
92
+ );
93
+
94
+ box.set(3);
95
+
96
+ expect(values).toEqual([4, 6]);
97
+ dispose();
98
+ });
99
+
100
+ it("makes objects observable", () => {
101
+ const adapter = createMobXReactivity();
102
+ const target = { count: 0 };
103
+ const observableTarget = adapter.makeObservable(target);
104
+ const values: number[] = [];
105
+
106
+ const dispose = adapter.reaction(
107
+ () => observableTarget.count,
108
+ (value) => values.push(value),
109
+ { fireImmediately: true }
110
+ );
111
+
112
+ adapter.runInAction(() => {
113
+ observableTarget.count = 1;
114
+ });
115
+
116
+ expect(values).toEqual([0, 1]);
117
+ dispose();
118
+ });
119
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ makeObservableProperty,
4
+ makeReferenceModelProperty,
5
+ } from "../src/index";
6
+
7
+ interface PropertyChange {
8
+ name: string;
9
+ oldValue: unknown;
10
+ newValue: unknown;
11
+ }
12
+
13
+ class TestModel {
14
+ _mobx: Record<string, { get(): unknown; set(value: unknown): void }> = {};
15
+ __data: Record<string, unknown> = { title: "Initial" };
16
+ declare title: string | undefined;
17
+ changes: PropertyChange[] = [];
18
+
19
+ propertyChanged(name: string, oldValue: unknown, newValue: unknown): void {
20
+ this.changes.push({ name, oldValue, newValue });
21
+ }
22
+ }
23
+
24
+ makeObservableProperty(TestModel.prototype, "title");
25
+
26
+ describe("makeObservableProperty", () => {
27
+ it("proxies through MobX boxes and tracks changes", () => {
28
+ const model = new TestModel();
29
+
30
+ expect(model.title).toBe("Initial");
31
+
32
+ model.title = "Updated";
33
+
34
+ expect(model.__data.title).toBe("Updated");
35
+ expect(model._mobx.title?.get()).toBe("Updated");
36
+ expect(model.changes).toEqual([
37
+ { name: "title", oldValue: "Initial", newValue: "Updated" },
38
+ ]);
39
+
40
+ model.title = "Again";
41
+
42
+ expect(model.changes).toEqual([
43
+ { name: "title", oldValue: "Initial", newValue: "Updated" },
44
+ { name: "title", oldValue: "Updated", newValue: "Again" },
45
+ ]);
46
+ });
47
+
48
+ it("falls back to __data when no MobX storage is present", () => {
49
+ const model = {
50
+ __data: { status: "open" },
51
+ changes: [] as PropertyChange[],
52
+ propertyChanged(name: string, oldValue: unknown, newValue: unknown) {
53
+ this.changes.push({ name, oldValue, newValue });
54
+ },
55
+ } as {
56
+ __data: Record<string, unknown>;
57
+ status?: string;
58
+ changes: PropertyChange[];
59
+ propertyChanged: (
60
+ name: string,
61
+ oldValue: unknown,
62
+ newValue: unknown
63
+ ) => void;
64
+ };
65
+
66
+ makeObservableProperty(model, "status");
67
+
68
+ expect(model.status).toBe("open");
69
+
70
+ model.status = "closed";
71
+
72
+ expect(model.__data.status).toBe("closed");
73
+ expect((model as { _mobx?: unknown })._mobx).toBeUndefined();
74
+ expect(model.changes).toEqual([
75
+ { name: "status", oldValue: "open", newValue: "closed" },
76
+ ]);
77
+ });
78
+ });
79
+
80
+ describe("makeReferenceModelProperty", () => {
81
+ it("gets and sets reference ids via the store", () => {
82
+ const store = {
83
+ get: (modelName: string, id: string) => ({ modelName, id }),
84
+ };
85
+
86
+ const model = {
87
+ store,
88
+ userId: "user-1" as string | null,
89
+ user: null as { id?: string } | null,
90
+ };
91
+
92
+ makeReferenceModelProperty(model, "user", "userId", "User");
93
+
94
+ expect(model.user).toEqual({ modelName: "User", id: "user-1" });
95
+
96
+ model.user = { id: "user-2" };
97
+
98
+ expect(model.userId).toBe("user-2");
99
+
100
+ model.user = null;
101
+
102
+ expect(model.userId).toBeNull();
103
+ });
104
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "sourceMap": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "strict": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noImplicitReturns": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "noUnusedLocals": true,
17
+ "noUnusedParameters": true,
18
+
19
+ "skipLibCheck": true,
20
+ "forceConsistentCasingInFileNames": true,
21
+ "esModuleInterop": true,
22
+ "resolveJsonModule": true,
23
+ "isolatedModules": true,
24
+ "useDefineForClassFields": true,
25
+ "experimentalDecorators": true
26
+ },
27
+ "include": ["src/**/*"],
28
+ "exclude": ["node_modules", "dist"]
29
+ }