@ng-org/alien-deepsignals 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/README.md ADDED
@@ -0,0 +1,476 @@
1
+ # NextGraph alien-deepsignals
2
+
3
+ Deep structural reactivity for plain objects / arrays / Sets built on top of `alien-signals`.
4
+
5
+ Hooks for Svelte, Vue, and React.
6
+
7
+ Core idea: wrap a data tree in a `Proxy` that lazily creates per-property signals the first time you read them. Deep mutations emit compact batched patch objects (in a JSON-patch inspired style) that you can track with `watch()`.
8
+
9
+ ## Features
10
+
11
+ - Lazy: signals & child proxies created only when touched.
12
+ - Deep: nested objects, arrays, Sets proxied.
13
+ - Per-property signals: fine‑grained invalidation without traversal on each change.
14
+ - Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters.
15
+ - Getter => computed: property getters become derived (readonly) signals automatically.
16
+ - `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays.
17
+ - Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids.
18
+ - Configurable synthetic IDs: custom property generator - the synthetic id is used in the paths of patches to identify objects in sets.
19
+ - Read-only properties: protect specific properties from modification.
20
+ - Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pnpm add @ng-org/alien-deepsignals
26
+ # or
27
+ npm i @ng-org/alien-deepsignals
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```ts
33
+ import { deepSignal } from "@ng-org/alien-deepsignals";
34
+
35
+ const state = deepSignal({
36
+ count: 0,
37
+ user: { name: "Ada" },
38
+ items: [{ id: "i1", qty: 1 }],
39
+ settings: new Set(["dark"]),
40
+ });
41
+
42
+ state.count++; // mutate normally
43
+ state.user.name = "Grace"; // nested write
44
+ state.items.push({ id: "i2", qty: 2 });
45
+ state.settings.add("beta");
46
+ ```
47
+
48
+ ## Frontend Hooks
49
+
50
+ We provide hooks for Svelte, Vue, and React so that you can use deepSignal objects in your frontend framework. Modifying the object within those components works as usual, just that the component will rerender automatically if the object changed (by an event in the component or a modification from elsewhere).
51
+
52
+ Note that you can pass existing deepSignal objects (that you are using elsewhere too, for example as shared state) as well as plain JavaScript objects (which are then wrapped).
53
+
54
+ ```tsx
55
+ import { useDeepSignal } from "@ng-org/alien-deepsignals/react";
56
+
57
+ const users = useDeepSignal([{username: "Bob"}]);
58
+ // Note: Instead of calling `setState`, you just need to modify a property. That will trigger the required re-render.
59
+ ```
60
+
61
+ ### Vue
62
+
63
+ In component `UserManager.vue`
64
+ ```vue
65
+ <script setup lang="ts">
66
+ import { DeepSignal } from "@ng-org/alien-deepsignals";
67
+ import { useDeepSignal } from "@ng-org/alien-deepsignals/vue";
68
+ import UserComponent from "./User.vue";
69
+ import { User } from "./types.ts";
70
+
71
+ const users: DeepSignal<User> = useDeepSignal([{username: "Bob", id: 1}]);
72
+ </script>
73
+
74
+ <template>
75
+ <UserComponent
76
+ v-for="user in users"
77
+ :key="user.id"
78
+ :user="user"
79
+ />
80
+ </template>
81
+ ```
82
+ In a child component, `User.vue`
83
+ ```vue
84
+ <script setup lang="ts">
85
+ import { useDeepSignal } from "@ng-org/alien-deepsignals/vue";
86
+
87
+ const props = defineProps<{
88
+ user: DeepSignal<User>;
89
+ }>();
90
+
91
+ // Important!
92
+ // In vue child components, you need to wrap deepSignal objects into useDeepSignal hooks, to ensure the component re-renders.
93
+ const user = useDeepSignal(props.user);
94
+ </script>
95
+ <template>
96
+ {{user.name}}
97
+ </template>
98
+ ```
99
+
100
+ ### Svelte
101
+
102
+ ```ts
103
+ import { useDeepSignal } from "@ng-org/alien-deepsignals/svelte";
104
+
105
+ // `users` is a rune of type `{username: string}[]`
106
+ const users = useDeepSignal([{username: "Bob"}]);
107
+ ```
108
+
109
+ ## Configuration options
110
+
111
+ `deepSignal(obj, options?)` accepts an optional configuration object:
112
+
113
+ ```ts
114
+ type DeepSignalOptions = {
115
+ propGenerator?: (props: {
116
+ path: (string | number)[];
117
+ inSet: boolean;
118
+ object: any;
119
+ }) => {
120
+ syntheticId?: string;
121
+ extraProps?: Record<string, unknown>;
122
+ };
123
+ syntheticIdPropertyName?: string;
124
+ readOnlyProps?: string[];
125
+ };
126
+ ```
127
+
128
+ ### Property generator function
129
+
130
+ The `propGenerator` function is called when a new object is added to the deep signal tree. It receives:
131
+
132
+ - `path`: The path of the newly added object
133
+ - `inSet`: Whether the object is being added to a Set (true) or not (false)
134
+ - `object`: The newly added object itself
135
+
136
+ It can return:
137
+
138
+ - `syntheticId`: A custom identifier for the object (used in Set entry paths and optionally as a property)
139
+ - `extraProps`: Additional properties to be added to the object (overwriting existing ones).
140
+
141
+ ```ts
142
+ let counter = 0;
143
+ const state = deepSignal(
144
+ { items: new Set() },
145
+ {
146
+ propGenerator: ({ path, inSet, object }) => ({
147
+ syntheticId: inSet
148
+ ? `urn:item:${++counter}`
149
+ : `urn:obj:${path.join("-")}`,
150
+ extraProps: { createdAt: new Date().toISOString() },
151
+ }),
152
+ syntheticIdPropertyName: "@id",
153
+ }
154
+ );
155
+
156
+ state.items.add({ name: "Item 1" }); // Gets @id: "urn:item:1" and createdAt property
157
+ state.items.add({ name: "Item 2" }); // Gets @id: "urn:item:2"
158
+ ```
159
+
160
+ ### Synthetic ID property name
161
+
162
+ When `syntheticIdPropertyName` is set (e.g., to `"@id"`), objects receive a readonly, enumerable property with the generated synthetic ID:
163
+
164
+ ```ts
165
+ const state = deepSignal(
166
+ { data: {} },
167
+ {
168
+ propGenerator: ({ path, inSet, object }) => ({
169
+ syntheticId: `urn:uuid:${crypto.randomUUID()}`,
170
+ }),
171
+ syntheticIdPropertyName: "@id",
172
+ }
173
+ );
174
+
175
+ state.data.user = { name: "Ada" };
176
+ console.log(state.data.user["@id"]); // e.g., "urn:uuid:550e8400-e29b-41d4-a716-446655440000"
177
+ ```
178
+
179
+ ### Read-only properties
180
+
181
+ The `readOnlyProps` option lets you specify property names that cannot be modified:
182
+
183
+ ```ts
184
+ const state = deepSignal(
185
+ { data: {} },
186
+ {
187
+ propGenerator: ({ path, inSet, object }) => ({
188
+ syntheticId: `urn:uuid:${crypto.randomUUID()}`,
189
+ }),
190
+ syntheticIdPropertyName: "@id",
191
+ readOnlyProps: ["@id", "@graph"],
192
+ }
193
+ );
194
+
195
+ state.data.user = { name: "Ada" };
196
+ state.data.user["@id"] = "new-id"; // TypeError: Cannot modify readonly property '@id'
197
+ ```
198
+
199
+ **Key behaviors:**
200
+
201
+ - Synthetic IDs are assigned **before** the object is proxied, ensuring availability immediately
202
+ - Properties specified in `readOnlyProps` are **readonly** and **enumerable**
203
+ - Synthetic ID assignment emits a patch just like any other property
204
+ - Objects with existing properties matching `syntheticIdPropertyName` keep their values (not overwritten)
205
+ - Options propagate to all nested objects created after initialization
206
+ - The `propGenerator` function is called for both Set entries (`inSet: true`) and regular objects (`inSet: false`)
207
+
208
+ ## Watching patches
209
+
210
+ `watch(root, cb, options?)` observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots.
211
+
212
+ ```ts
213
+ import { watch } from "alien-deepsignals";
214
+
215
+ const stop = watch(state, ({ patches, oldValue, newValue }) => {
216
+ for (const p of patches) {
217
+ console.log(p.op, p.path.join("."), "value" in p ? p.value : p.type);
218
+ }
219
+ });
220
+
221
+ state.user.name = "Lin";
222
+ state.items[0].qty = 3;
223
+ await Promise.resolve(); // flush microtask
224
+ stop();
225
+ ```
226
+
227
+ ## Computed (derived) values
228
+
229
+ Use the `computed()` function to create lazy derived signals that automatically track their dependencies and recompute only when needed.
230
+
231
+ ```ts
232
+ import { computed } from "@ng-org/alien-deepsignals";
233
+
234
+ const state = deepSignal({
235
+ firstName: "Ada",
236
+ lastName: "Lovelace",
237
+ items: [1, 2, 3],
238
+ });
239
+
240
+ // Create a computed signal that derives from reactive state
241
+ const fullNaAdd documentationme = computed(() => `${state.firstName} ${state.lastName}`);
242
+ const itemCount = computed(() => state.items.length);
243
+
244
+ console.log(fullName()); // "Ada Lovelace" - computes on first access
245
+ console.log(itemCount()); // 3
246
+
247
+ state.firstName = "Grace";
248
+ console.log(fullName()); // "Grace Lovelace" - recomputes automatically
249
+ ```
250
+
251
+ **Key benefits:**
252
+
253
+ - **Lazy evaluation**: The computation runs only when you actually read the computed value. If you never access `fullName()`, the concatenation never happens—no wasted CPU cycles.
254
+ - **Automatic caching**: Once computed, the result is cached until a dependency changes. Multiple reads return the cached value without re-running the getter.
255
+ - **Fine-grained reactivity**: Only recomputes when its tracked dependencies change. Unrelated state mutations don't trigger unnecessary recalculation.
256
+ - **Composable**: Computed signals can depend on other computed signals, forming efficient dependency chains.
257
+
258
+ ```ts
259
+ // Expensive computation only runs when accessed and dependencies change
260
+ const expensiveResult = computed(() => {
261
+ console.log("Computing...");
262
+ return state.items.reduce((sum, n) => sum + n * n, 0);
263
+ });
264
+
265
+ // No computation happens yet!
266
+ state.items.push(4);
267
+ // Still no computation...
268
+
269
+ console.log(expensiveResult()); // "Computing..." + result
270
+ console.log(expensiveResult()); // Cached, no log
271
+ state.items.push(5);
272
+ console.log(expensiveResult()); // "Computing..." again (dependency changed)
273
+ ```
274
+
275
+ ### Callback event shape
276
+
277
+ ```ts
278
+ type WatchPatchEvent<T> = {
279
+ patches: DeepPatch[]; // empty only on immediate
280
+ oldValue: T | undefined; // deep-cloned snapshot before batch
281
+ newValue: T; // live proxy (already mutated)
282
+ registerCleanup(fn): void; // register disposer for next batch/stop
283
+ stopListening(): void; // unsubscribe
284
+ };
285
+ ```
286
+
287
+ ### Options
288
+
289
+ | Option | Type | Default | Description |
290
+ | ----------- | ------- | ------- | -------------------------------------------------- |
291
+ | `immediate` | boolean | false | Fire once right away with `patches: []`. |
292
+ | `once` | boolean | false | Auto stop after first callback (immediate counts). |
293
+
294
+ `observe()` is an alias of `watch()`.
295
+
296
+ ## DeepPatch format
297
+
298
+ ```ts
299
+ type DeepPatch = {
300
+ root: symbol; // stable id per deepSignal root
301
+ path: (string | number)[]; // root-relative segments
302
+ } & (
303
+ | { op: "add"; type: "object" } // assigned object/array/Set entry object
304
+ | { op: "add"; value: string | number | boolean } // primitive write
305
+ | { op: "remove" } // deletion
306
+ | { op: "add"; type: "set"; value: [] } // Set.clear()
307
+ | {
308
+ op: "add";
309
+ type: "set";
310
+ value: (string | number | boolean)[] | { [id: string]: object };
311
+ } // (reserved)
312
+ );
313
+ ```
314
+
315
+ Notes:
316
+
317
+ - `type:'object'` omits value to avoid deep cloning; read from `newValue` if needed.
318
+ - `Set.add(entry)` emits object vs primitive form depending on entry type; path ends with synthetic id.
319
+ - `Set.clear()` emits one structural patch and suppresses per‑entry removals in same batch.
320
+
321
+ ## Sets & synthetic ids
322
+
323
+ Object entries inside Sets need a stable key for patch paths. The synthetic ID resolution follows this priority:
324
+
325
+ 1. Explicit custom ID via `setSetEntrySyntheticId(entry, 'myId')` (before `add`)
326
+ 2. Custom ID property specified by `syntheticIdPropertyName` option (e.g., `entry['@id']`)
327
+ 3. Auto-generated blank node ID (`_bN` format)
328
+
329
+ ### Working with Sets
330
+
331
+ ```ts
332
+ import { addWithId, setSetEntrySyntheticId } from "@ng-org/alien-deepsignals";
333
+
334
+ // Option 1: Use automatic ID generation via propGenerator
335
+ const state = deepSignal(
336
+ { items: new Set() },
337
+ {
338
+ propGenerator: ({ path, inSet, object }) => ({
339
+ syntheticId: inSet ? `urn:uuid:${crypto.randomUUID()}` : undefined,
340
+ }),
341
+ syntheticIdPropertyName: "@id",
342
+ }
343
+ );
344
+ const item = { name: "Item 1" };
345
+ state.items.add(item); // Automatically gets @id before being added
346
+ console.log(item["@id"]); // e.g., "urn:uuid:550e8400-..."
347
+
348
+ // Option 2: Manually set synthetic ID
349
+ const obj = { value: 42 };
350
+ setSetEntrySyntheticId(obj, "urn:custom:my-id");
351
+ state.items.add(obj);
352
+
353
+ // Option 3: Use convenience helper
354
+ addWithId(state.items as any, { value: 99 }, "urn:item:special");
355
+
356
+ // Option 4: Pre-assign property matching syntheticIdPropertyName
357
+ const preTagged = { "@id": "urn:explicit:123", data: "..." };
358
+ state.items.add(preTagged); // Uses "urn:explicit:123" as synthetic ID
359
+ ```
360
+
361
+ ### Set entry patches and paths
362
+
363
+ When objects are added to Sets, their **synthetic ID becomes part of the patch path**. This allows patches to uniquely identify which Set entry is being mutated.
364
+
365
+ ```ts
366
+ const state = deepSignal(
367
+ { s: new Set() },
368
+ {
369
+ propGenerator: ({ inSet }) => ({
370
+ syntheticId: inSet ? "urn:entry:set-entry-1" : undefined,
371
+ }),
372
+ syntheticIdPropertyName: "@id",
373
+ }
374
+ );
375
+
376
+ watch(state, ({ patches }) => {
377
+ console.log(JSON.stringify(patches));
378
+ // [
379
+ // {"path":["s","urn:entry:set-entry-1"],"op":"add","type":"object"},
380
+ // {"path":["s","urn:entry:set-entry-1","@id"],"op":"add","value":"urn:entry:set-entry-1"},
381
+ // {"path":["s","urn:entry:set-entry-1","data"],"op":"add","value":"test"}
382
+ // ]
383
+ });
384
+
385
+ state.s.add({ data: "test" });
386
+ ```
387
+
388
+ **Path structure explained:**
389
+
390
+ - `["s", "urn:entry:set-entry-1"]` - The structural Set patch; the IRI identifies the entry
391
+ - `["s", "urn:entry:set-entry-1", "@id"]` - Patch for the @id property assignment
392
+ - `["s", "urn:entry:set-entry-1", "data"]` - Nested property patch; the IRI identifies which Set entry
393
+ - The synthetic ID (the IRI) is stable across mutations, allowing tracking of the same object
394
+
395
+ **Mutating nested properties:**
396
+
397
+ ```ts
398
+ const state = deepSignal(
399
+ { users: new Set() },
400
+ {
401
+ propGenerator: ({ path, inSet }) => ({
402
+ syntheticId: inSet ? `urn:user:${crypto.randomUUID()}` : undefined,
403
+ }),
404
+ syntheticIdPropertyName: "@id",
405
+ }
406
+ );
407
+ const user = { name: "Ada", age: 30 };
408
+ state.users.add(user); // Gets @id, e.g., "urn:user:550e8400-..."
409
+
410
+ watch(state, ({ patches }) => {
411
+ console.log(JSON.stringify(patches));
412
+ // [{"path":["users","urn:user:550e8400-...","age"],"op":"add","value":31}]
413
+ });
414
+
415
+ // Later mutation: synthetic ID identifies which Set entry changed
416
+ user.age = 31;
417
+ ```
418
+
419
+ The path `["users", "urn:user:550e8400-...", "age"]` shows:
420
+
421
+ 1. `users` - the Set container
422
+ 2. `urn:user:550e8400-...` - the IRI identifying which object in the Set
423
+ 3. `age` - the property being mutated
424
+
425
+ This structure enables precise tracking of nested changes within Set entries, critical for syncing state changes or implementing undo/redo.
426
+
427
+ ## Shallow
428
+
429
+ Skip deep proxying of a subtree (only reference replacement tracked):
430
+
431
+ ```ts
432
+ import { shallow } from "alien-deepsignals";
433
+ state.config = shallow({ huge: { blob: true } });
434
+ ```
435
+
436
+ ## TypeScript ergonomics
437
+
438
+ `DeepSignal<T>` exposes both plain properties and optional `$prop` signal accessors (excluded for function members). Arrays add `$` (index signal map) and `$length`.
439
+
440
+ ```ts
441
+ const state = deepSignal({ count: 0, user: { name: "A" } });
442
+ state.count++; // ok
443
+ state.$count!.set(9); // write via signal
444
+ const n: number = state.$count!(); // typed number
445
+ ```
446
+
447
+ ## API surface
448
+
449
+ | Function | Description |
450
+ | ---------------------------------- | ------------------------------------------------------------------ |
451
+ | `deepSignal(obj, options?)` | Create (or reuse) reactive deep proxy with optional configuration. |
452
+ | `watch(root, cb, opts?)` | Observe batched deep mutations. |
453
+ | `observe(root, cb, opts?)` | Alias of `watch`. |
454
+ | `peek(obj,key)` | Untracked property read. |
455
+ | `shallow(obj)` | Mark object to skip deep proxying. |
456
+ | `isDeepSignal(val)` | Runtime predicate. |
457
+ | `isShallow(val)` | Was value marked shallow. |
458
+ | `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id (highest priority). |
459
+ | `addWithId(set, entry, id)` | Insert with desired synthetic id (convenience). |
460
+ | `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). |
461
+
462
+ ## License
463
+
464
+ This project is a fork of https://github.com/CCherry07/alien-deepsignals, forked at commit `b691dc9202c58f63c1bf78675577c811316396db`. All code previous to this commit is licensed under MIT, and author is CCherry. No copyright attribution is present. This codebase is therefor relicensed under dual MIT and Apache 2.0 licensing.
465
+
466
+ All subsequent commits are from Laurin Weger and are licensed under either of
467
+
468
+ - Apache License, Version 2.0 ([LICENSE-APACHE2](LICENSE-APACHE2) or http://www.apache.org/licenses/LICENSE-2.0)
469
+ - MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)
470
+ at your option.
471
+
472
+ `SPDX-License-Identifier: Apache-2.0 OR MIT`
473
+
474
+ ---
475
+
476
+ NextGraph received funding through the [NGI Assure Fund](https://nlnet.nl/assure) and the [NGI Zero Commons Fund](https://nlnet.nl/commonsfund/), both funds established by [NLnet](https://nlnet.nl/) Foundation with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreements No 957073 and No 101092990, respectively.