Rust implementation of NextGraph, a Decentralized and local-first web 3.0 ecosystem https://nextgraph.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
nextgraph-rs/sdk/js/alien-deepsignals
Laurin Weger 624d0c5a6d
update deepsignals readme
1 day ago
..
src deep signals uses @id semantics, support for custom @id generator fn 1 day ago
.gitignore moving things around 7 days ago
README.md update deepsignals readme 1 day ago
package.json moving things around 7 days ago
pnpm-lock.yaml moving things around 7 days ago
tsconfig.json moving things around 7 days ago
tsup.config.ts moving things around 7 days ago

README.md

NextGraph alien-deepsignals

Deep structural reactivity for plain objects / arrays / Sets built on top of alien-signals.

Core idea: wrap a data tree in a Proxy that lazily creates per-property signals the first time you read them. Accessing a property returns the plain value; accessing $prop returns the underlying signal function. Deep mutations emit compact batched patch objects you can observe with watch().

Features

  • Lazy: signals & child proxies created only when touched.
  • Deep: nested objects, arrays, Sets proxied on demand.
    • TODO: Methods might not be proxied (e.g. array.push)?
  • Per-property signals: fine‑grained invalidation without traversal on each change.
  • Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters.
  • Getter => computed: property getters become derived (readonly) signals automatically.
  • $ accessors: TypeScript exposes $prop for each non‑function key plus $ / $length for arrays.
  • Sets: structural add/delete/clear emit patches; object entries get synthetic stable ids via @id property.
  • @id property system: configurable automatic ID assignment to objects with custom generators.
  • Shallow escape hatch: wrap sub-objects with shallow(obj) to track only reference replacement.

Install

pnpm add @ng-org/alien-deepsignals
# or
npm i @ng-org/alien-deepsignals

Quick start

import { deepSignal } from "@ng-org/alien-deepsignals";

const state = deepSignal({
    count: 0,
    user: { name: "Ada" },
    items: [{ id: "i1", qty: 1 }],
    settings: new Set(["dark"]),
});

state.count++; // mutate normally
state.user.name = "Grace"; // nested write
state.items.push({ id: "i2", qty: 2 });
state.settings.add("beta");

// Direct signal access
state.$count!.set(5); // update via signal
console.log(state.$count!()); // read via signal function

Configuration options

deepSignal(obj, options?) accepts an optional configuration object:

type DeepSignalOptions = {
    idGenerator?: () => string | number; // Custom ID generator function
    addIdToObjects?: boolean; // Automatically add @id to plain objects
};

Custom ID generation

Provide a custom function to generate synthetic IDs instead of auto-generated blank node IDs:

let counter = 0;
const state = deepSignal(
    { items: new Set() },
    { 
        idGenerator: () => `urn:item:${++counter}`,
        addIdToObjects: true 
    }
);

state.items.add({ name: "Item 1" }); // Gets @id: "urn:item:1"
state.items.add({ name: "Item 2" }); // Gets @id: "urn:item:2"

The @id property

When addIdToObjects: true, plain objects automatically receive a readonly, enumerable @id property:

const state = deepSignal(
    { data: {} },
    { 
        idGenerator: () => `urn:uuid:${crypto.randomUUID()}`,
        addIdToObjects: true 
    }
);

state.data.user = { name: "Ada" };
console.log(state.data.user["@id"]); // e.g., "urn:uuid:550e8400-e29b-41d4-a716-446655440000"

// @id is readonly
state.data.user["@id"] = "new-id"; // TypeError in strict mode

// @id assignment emits a patch
watch(state, ({ patches }) => {
    // patches includes: { op: "add", path: ["data", "user", "@id"], value: "..." }
});

Key behaviors:

  • @id is assigned before the object is proxied, ensuring it's available immediately
  • @id properties are readonly and enumerable
  • Assigning @id emits a patch just like any other property
  • Objects with existing @id properties keep their values (not overwritten)
  • Options propagate to nested objects created after initialization

Watching patches

watch(root, cb, options?) observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots.

import { watch } from "alien-deepsignals";

const stop = watch(state, ({ patches, oldValue, newValue }) => {
    for (const p of patches) {
        console.log(p.op, p.path.join("."), "value" in p ? p.value : p.type);
    }
});

state.user.name = "Lin";
state.items[0].qty = 3;
await Promise.resolve(); // flush microtask
stop();

Callback event shape

type WatchPatchEvent<T> = {
    patches: DeepPatch[]; // empty only on immediate
    oldValue: T | undefined; // deep-cloned snapshot before batch
    newValue: T; // live proxy (already mutated)
    registerCleanup(fn): void; // register disposer for next batch/stop
    stopListening(): void; // unsubscribe
};

Options

Option Type Default Description
immediate boolean false Fire once right away with patches: [].
once boolean false Auto stop after first callback (immediate counts).

observe() is an alias of watch().

DeepPatch format

type DeepPatch = {
    root: symbol; // stable id per deepSignal root
    path: (string | number)[]; // root-relative segments
} & (
    | { op: "add"; type: "object" } // assigned object/array/Set entry object
    | { op: "add"; value: string | number | boolean } // primitive write
    | { op: "remove" } // deletion
    | { op: "add"; type: "set"; value: [] } // Set.clear()
    | {
          op: "add";
          type: "set";
          value: (string | number | boolean)[] | { [id: string]: object };
      } // (reserved)
);

Notes:

  • type:'object' omits value to avoid deep cloning; read from newValue if needed.
  • Set.add(entry) emits object vs primitive form depending on entry type; path ends with synthetic id.
  • Set.clear() emits one structural patch and suppresses per‑entry removals in same batch.

Sets & synthetic ids

Object entries inside Sets need a stable key for patch paths. The synthetic ID resolution follows this priority:

  1. Explicit custom ID via setSetEntrySyntheticId(entry, 'myId') (before add)
  2. Existing entry['@id'] property
  3. Auto-generated blank node ID (_bN format)

Working with Sets

import { addWithId, setSetEntrySyntheticId } from "@ng-org/alien-deepsignals";

// Option 1: Use @id from configuration
const state = deepSignal(
    { items: new Set() },
    { 
        idGenerator: () => `urn:uuid:${crypto.randomUUID()}`,
        addIdToObjects: true 
    }
);
const item = { name: "Item 1" };
state.items.add(item); // Automatically gets @id before being added
console.log(item["@id"]); // e.g., "urn:uuid:550e8400-..."

// Option 2: Manually set synthetic ID
const obj = { value: 42 };
setSetEntrySyntheticId(obj, "urn:custom:my-id");
state.items.add(obj);

// Option 3: Use convenience helper
addWithId(state.items as any, { value: 99 }, "urn:item:special");

// Option 4: Pre-assign @id property
const preTagged = { "@id": "urn:explicit:123", data: "..." };
state.items.add(preTagged); // Uses "urn:explicit:123" as synthetic ID

Set entry patches and paths

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.

const state = deepSignal(
    { s: new Set() }, 
    { 
        idGenerator: () => "urn:entry:set-entry-1",
        addIdToObjects: true 
    }
);

watch(state, ({ patches }) => {
    console.log(JSON.stringify(patches));
    // [
    //   {"path":["s","urn:entry:set-entry-1"],"op":"add","type":"object"},
    //   {"path":["s","urn:entry:set-entry-1","@id"],"op":"add","value":"urn:entry:set-entry-1"},
    //   {"path":["s","urn:entry:set-entry-1","data"],"op":"add","value":"test"}
    // ]
});

state.s.add({ data: "test" });

Path structure explained:

  • ["s", "urn:entry:set-entry-1"] - The structural Set patch; the IRI identifies the entry
  • ["s", "urn:entry:set-entry-1", "@id"] - Patch for the @id property assignment
  • ["s", "urn:entry:set-entry-1", "data"] - Nested property patch; the IRI identifies which Set entry
  • The synthetic ID (the IRI) is stable across mutations, allowing tracking of the same object

Mutating nested properties:

const state = deepSignal(
    { users: new Set() }, 
    { 
        idGenerator: () => `urn:user:${crypto.randomUUID()}`,
        addIdToObjects: true 
    }
);
const user = { name: "Ada", age: 30 };
state.users.add(user); // Gets @id, e.g., "urn:user:550e8400-..."

watch(state, ({ patches }) => {
    console.log(JSON.stringify(patches));
    // [{"path":["users","urn:user:550e8400-...","age"],"op":"add","value":31}]
});

// Later mutation: synthetic ID identifies which Set entry changed
user.age = 31;

The path ["users", "urn:user:550e8400-...", "age"] shows:

  1. users - the Set container
  2. urn:user:550e8400-... - the IRI identifying which object in the Set
  3. age - the property being mutated

This structure enables precise tracking of nested changes within Set entries, critical for syncing state changes or implementing undo/redo.

Shallow

Skip deep proxying of a subtree (only reference replacement tracked):

import { shallow } from "alien-deepsignals";
state.config = shallow({ huge: { blob: true } });

TypeScript ergonomics

DeepSignal<T> exposes both plain properties and optional $prop signal accessors (excluded for function members). Arrays add $ (index signal map) and $length.

const state = deepSignal({ count: 0, user: { name: "A" } });
state.count++; // ok
state.$count!.set(9); // write via signal
const n: number = state.$count!(); // typed number

API surface

Function Description
deepSignal(obj, options?) Create (or reuse) reactive deep proxy with optional configuration.
watch(root, cb, opts?) Observe batched deep mutations.
observe(root, cb, opts?) Alias of watch.
peek(obj,key) Untracked property read.
shallow(obj) Mark object to skip deep proxying.
isDeepSignal(val) Runtime predicate.
isShallow(val) Was value marked shallow.
setSetEntrySyntheticId(obj,id) Assign custom Set entry id (highest priority).
addWithId(set, entry, id) Insert with desired synthetic id (convenience).
subscribeDeepMutations(root, cb) Low-level patch stream (used by watch).

Credits

This project is a fork of https://github.com/CCherry07/alien-deepsignals.

License

MIT