feat/orm-diffs
Laurin Weger 20 hours ago
parent 6fd046fd2a
commit f45b95750a
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 7
      sdk/js/examples/multi-framework-signals/src/app/pages/index.astro
  2. 7
      sdk/js/signals/package.json
  3. 243
      sdk/js/signals/src/connector/createSignalObjectForShape.ts
  4. 252
      sdk/js/signals/src/connector/createSignalObjectForShape.ts.old
  5. 7
      sdk/js/signals/src/connector/initNg.ts
  6. 17
      sdk/js/signals/src/connector/ormConnectionHandler.ts
  7. 2
      sdk/js/signals/src/index.ts

@ -5,12 +5,14 @@ import Highlight from "../components/Highlight.astro";
import VueRoot from "../components/VueRoot.vue";
import ReactRoot from "../components/ReactRoot";
import SvelteRoot from "../components/SvelteRoot.svelte";
import { initNg } from "@ng-org/signals"
const title = "Multi-framework app";
---
<script>
import { ng, init } from "@ng-org/web";
import { initNg } from "@ng-org/signals";
await init(
(event: {
status: string;
@ -21,7 +23,8 @@ const title = "Multi-framework app";
public_store_id: unknown;
};
}) => {
console.log(event.status, event.session.session_id);
console.log("ng web initialized. event.status, session_id:", event.status, event.session.session_id);
initNg(ng);
},
true,
[]

@ -10,6 +10,9 @@
"build:ts": "rm -rf dist && tsc"
},
"exports": {
".": {
"default": "./src/index.ts"
},
"./react": {
"default": "./src/frontendAdapters/react/index.ts"
},
@ -24,6 +27,10 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./react": {
"types": "./dist/frontendAdapters/react/index.d.ts",
"default": "./dist/frontendAdapters/react/index.js"

@ -1,168 +1,6 @@
import type { Diff, Scope } from "../types.js";
import { applyDiff } from "./applyDiff.js";
import * as NG from "@ng-org/lib-wasm";
import { deepSignal, watch, batch } from "@ng-org/alien-deepsignals";
import type { DeepPatch, DeepSignalObject } from "@ng-org/alien-deepsignals";
import type { ShapeType, BaseType } from "@ng-org/shex-orm";
interface PoolEntry<T extends BaseType> {
connectionId: string;
key: string;
shapeType: ShapeType<T>;
scopeKey: string;
signalObject: DeepSignalObject<T | {}>;
refCount: number;
suspendDeepWatcher: boolean;
ready: boolean;
// Promise that resolves once initial data has been applied.
readyPromise: Promise<void>;
resolveReady: () => void;
release: () => void;
}
interface WasmMessage {
type:
| "Request"
| "InitialResponse"
| "FrontendUpdate"
| "BackendUpdate"
| "Stop";
connectionId: string;
diff?: Diff;
shapeType?: ShapeType<any>;
initialData?: BaseType;
}
function canonicalScope(scope: Scope | undefined): string {
if (scope == null) return "";
return Array.isArray(scope)
? scope.slice().sort().join(",")
: String(scope);
}
function decodePathSegment(segment: string): string {
return segment.replace("~1", "/").replace("~0", "~");
}
function escapePathSegment(segment: string): string {
return segment.replace("~", "~0").replace("/", "~1");
}
export function deepPatchesToDiff(patches: DeepPatch[]): Diff {
return patches.map((patch) => {
const path =
"/" +
patch.path.map((el) => escapePathSegment(el.toString())).join("/");
return { ...patch, path };
}) as Diff;
}
const recurseArrayToSet = (obj: any): any => {
if (Array.isArray(obj)) {
return new Set(obj.map(recurseArrayToSet));
} else if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
obj[key] = recurseArrayToSet(obj[key]);
}
return obj;
} else {
return obj;
}
};
const handleInitialResponse = (
entry: PoolEntry<any>,
wasmMessage: WasmMessage
) => {
const { connectionId, initialData } = wasmMessage;
const { signalObject } = entry;
// Assign initial data to empty signal object without triggering watcher at first.
entry.suspendDeepWatcher = true;
batch(() => {
// Convert arrays to sets and apply to signalObject (we only have sets but can only transport arrays).
Object.assign(signalObject, recurseArrayToSet(initialData)!);
});
// Add listener to deep signal object to report changes back to wasm land.
const watcher = watch(signalObject, ({ patches }) => {
if (entry.suspendDeepWatcher || !patches.length) return;
const diff = deepPatchesToDiff(patches);
// Send FrontendUpdate message to wasm land.
const msg: WasmMessage = {
type: "FrontendUpdate",
connectionId,
diff: JSON.parse(JSON.stringify(diff)),
};
communicationChannel.postMessage(msg);
});
queueMicrotask(() => {
entry.suspendDeepWatcher = false;
// Resolve readiness after initial data is committed and watcher armed.
entry.resolveReady?.();
});
// Schedule cleanup of the connection when the signal object is GC'd.
cleanupSignalRegistry?.register(
entry.signalObject,
entry.connectionId,
entry.signalObject
);
entry.ready = true;
};
// Handler for messages from wasm land.
const onMessage = (event: MessageEvent<WasmMessage>) => {
console.debug("[JsLand] onWasmMessage", event);
const { diff, connectionId, type } = event.data;
// Only process messages for objects we track.
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
// And only process messages that are addressed to js-land.
if (type === "FrontendUpdate") return;
if (type === "Request") {
// TODO: Handle message from wasm land and js land
// in different functions
return;
}
if (type === "Stop") return;
if (type === "InitialResponse") {
handleInitialResponse(entry, event.data);
} else if (type === "BackendUpdate" && diff) {
applyDiff(entry.signalObject, diff);
} else {
console.warn("[JsLand] Unknown message type", event);
}
};
// TODO: Should those be WeekMaps?
const keyToEntry = new Map<string, PoolEntry<any>>();
const connectionIdToEntry = new Map<string, PoolEntry<any>>();
const communicationChannel = new BroadcastChannel("shape-manager");
communicationChannel.addEventListener("message", onMessage);
// FinalizationRegistry to clean up connections when signal objects are GC'd.
const cleanupSignalRegistry =
typeof FinalizationRegistry === "function"
? new FinalizationRegistry<string>((connectionId) => {
// Best-effort fallback; look up by id and clean
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
entry.release();
})
: null;
import { OrmConnection } from "./ormConnectionHandler.ts";
/**
*
@ -172,81 +10,16 @@ const cleanupSignalRegistry =
*/
export function createSignalObjectForShape<T extends BaseType>(
shapeType: ShapeType<T>,
ng: typeof NG,
scope?: Scope
) {
const scopeKey = canonicalScope(scope);
// Unique identifier for a given shape type and scope.
const key = `${shapeType.shape}::${scopeKey}`;
// If we already have an object for this shape+scope, return it
// and just increase the reference count.
const existing = keyToEntry.get(key);
if (existing) {
existing.refCount++;
return buildReturn(existing);
}
// Otherwise, create a new signal object and an entry for it.
const signalObject = deepSignal<T | {}>(new Set());
// Create entry to keep track of the connection with the backend.
const entry: PoolEntry<T> = {
key,
// The id for future communication between wasm and js land.
// TODO
connectionId: `${key}_${new Date().toISOString()}`,
const connection: OrmConnection<T> = OrmConnection.getConnection(
shapeType,
scopeKey,
signalObject,
refCount: 1,
suspendDeepWatcher: false,
ready: false,
// readyPromise will be set just below
readyPromise: Promise.resolve(),
resolveReady: () => {},
// Function to manually release the connection.
// Only releases if refCount is 0.
release: () => {
if (entry.refCount > 0) entry.refCount--;
if (entry.refCount === 0) {
communicationChannel.postMessage({
type: "Stop",
connectionId: entry.connectionId,
} as WasmMessage);
keyToEntry.delete(entry.key);
connectionIdToEntry.delete(entry.connectionId);
scope || ""
);
// In your manual release
cleanupSignalRegistry?.unregister(entry.signalObject);
}
},
return {
signalObject: connection.signalObject,
stop: connection.release,
readyPromise: connection.readyPromise,
};
// Initialize per-entry readiness promise that resolves in setUpConnection
entry.readyPromise = new Promise<void>((resolve) => {
entry.resolveReady = resolve;
});
keyToEntry.set(key, entry);
connectionIdToEntry.set(entry.connectionId, entry);
communicationChannel.postMessage({
type: "Request",
connectionId: entry.connectionId,
shapeType,
} as WasmMessage);
function buildReturn(entry: PoolEntry<T>) {
return {
signalObject: entry.signalObject,
stop: entry.release,
connectionId: entry.connectionId,
readyPromise: entry.readyPromise,
};
}
return buildReturn(entry);
}

@ -0,0 +1,252 @@
import type { Diff, Scope } from "../types.js";
import { applyDiff } from "./applyDiff.js";
import * as NG from "@ng-org/lib-wasm";
import { deepSignal, watch, batch } from "@ng-org/alien-deepsignals";
import type { DeepPatch, DeepSignalObject } from "@ng-org/alien-deepsignals";
import type { ShapeType, BaseType } from "@ng-org/shex-orm";
interface PoolEntry<T extends BaseType> {
connectionId: string;
key: string;
shapeType: ShapeType<T>;
scopeKey: string;
signalObject: DeepSignalObject<T | {}>;
refCount: number;
suspendDeepWatcher: boolean;
ready: boolean;
// Promise that resolves once initial data has been applied.
readyPromise: Promise<void>;
resolveReady: () => void;
release: () => void;
}
interface WasmMessage {
type:
| "Request"
| "InitialResponse"
| "FrontendUpdate"
| "BackendUpdate"
| "Stop";
connectionId: string;
diff?: Diff;
shapeType?: ShapeType<any>;
initialData?: BaseType;
}
function canonicalScope(scope: Scope | undefined): string {
if (scope == null) return "";
return Array.isArray(scope)
? scope.slice().sort().join(",")
: String(scope);
}
function decodePathSegment(segment: string): string {
return segment.replace("~1", "/").replace("~0", "~");
}
function escapePathSegment(segment: string): string {
return segment.replace("~", "~0").replace("/", "~1");
}
export function deepPatchesToDiff(patches: DeepPatch[]): Diff {
return patches.map((patch) => {
const path =
"/" +
patch.path.map((el) => escapePathSegment(el.toString())).join("/");
return { ...patch, path };
}) as Diff;
}
const recurseArrayToSet = (obj: any): any => {
if (Array.isArray(obj)) {
return new Set(obj.map(recurseArrayToSet));
} else if (obj && typeof obj === "object") {
for (const key of Object.keys(obj)) {
obj[key] = recurseArrayToSet(obj[key]);
}
return obj;
} else {
return obj;
}
};
const handleInitialResponse = (
entry: PoolEntry<any>,
wasmMessage: WasmMessage
) => {
const { connectionId, initialData } = wasmMessage;
const { signalObject } = entry;
// Assign initial data to empty signal object without triggering watcher at first.
entry.suspendDeepWatcher = true;
batch(() => {
// Convert arrays to sets and apply to signalObject (we only have sets but can only transport arrays).
Object.assign(signalObject, recurseArrayToSet(initialData)!);
});
// Add listener to deep signal object to report changes back to wasm land.
const watcher = watch(signalObject, ({ patches }) => {
if (entry.suspendDeepWatcher || !patches.length) return;
const diff = deepPatchesToDiff(patches);
// Send FrontendUpdate message to wasm land.
const msg: WasmMessage = {
type: "FrontendUpdate",
connectionId,
diff: JSON.parse(JSON.stringify(diff)),
};
communicationChannel.postMessage(msg);
});
queueMicrotask(() => {
entry.suspendDeepWatcher = false;
// Resolve readiness after initial data is committed and watcher armed.
entry.resolveReady?.();
});
// Schedule cleanup of the connection when the signal object is GC'd.
cleanupSignalRegistry?.register(
entry.signalObject,
entry.connectionId,
entry.signalObject
);
entry.ready = true;
};
// Handler for messages from wasm land.
const onMessage = (event: MessageEvent<WasmMessage>) => {
console.debug("[JsLand] onWasmMessage", event);
const { diff, connectionId, type } = event.data;
// Only process messages for objects we track.
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
// And only process messages that are addressed to js-land.
if (type === "FrontendUpdate") return;
if (type === "Request") {
// TODO: Handle message from wasm land and js land
// in different functions
return;
}
if (type === "Stop") return;
if (type === "InitialResponse") {
handleInitialResponse(entry, event.data);
} else if (type === "BackendUpdate" && diff) {
applyDiff(entry.signalObject, diff);
} else {
console.warn("[JsLand] Unknown message type", event);
}
};
// TODO: Should those be WeekMaps?
const keyToEntry = new Map<string, PoolEntry<any>>();
const connectionIdToEntry = new Map<string, PoolEntry<any>>();
const communicationChannel = new BroadcastChannel("shape-manager");
communicationChannel.addEventListener("message", onMessage);
// FinalizationRegistry to clean up connections when signal objects are GC'd.
const cleanupSignalRegistry =
typeof FinalizationRegistry === "function"
? new FinalizationRegistry<string>((connectionId) => {
// Best-effort fallback; look up by id and clean
const entry = connectionIdToEntry.get(connectionId);
if (!entry) return;
entry.release();
})
: null;
/**
*
* @param shapeType
* @param scope
* @returns
*/
export function createSignalObjectForShape<T extends BaseType>(
shapeType: ShapeType<T>,
ng: typeof NG,
scope?: Scope
) {
const scopeKey = canonicalScope(scope);
// Unique identifier for a given shape type and scope.
const key = `${shapeType.shape}::${scopeKey}`;
// If we already have an object for this shape+scope, return it
// and just increase the reference count.
const existing = keyToEntry.get(key);
if (existing) {
existing.refCount++;
return buildReturn(existing);
}
// Otherwise, create a new signal object and an entry for it.
const signalObject = deepSignal<T | {}>(new Set());
// Create entry to keep track of the connection with the backend.
const entry: PoolEntry<T> = {
key,
// The id for future communication between wasm and js land.
// TODO
connectionId: `${key}_${new Date().toISOString()}`,
shapeType,
scopeKey,
signalObject,
refCount: 1,
suspendDeepWatcher: false,
ready: false,
// readyPromise will be set just below
readyPromise: Promise.resolve(),
resolveReady: () => {},
// Function to manually release the connection.
// Only releases if refCount is 0.
release: () => {
if (entry.refCount > 0) entry.refCount--;
if (entry.refCount === 0) {
communicationChannel.postMessage({
type: "Stop",
connectionId: entry.connectionId,
} as WasmMessage);
keyToEntry.delete(entry.key);
connectionIdToEntry.delete(entry.connectionId);
// In your manual release
cleanupSignalRegistry?.unregister(entry.signalObject);
}
},
};
// Initialize per-entry readiness promise that resolves in setUpConnection
entry.readyPromise = new Promise<void>((resolve) => {
entry.resolveReady = resolve;
});
keyToEntry.set(key, entry);
connectionIdToEntry.set(entry.connectionId, entry);
communicationChannel.postMessage({
type: "Request",
connectionId: entry.connectionId,
shapeType,
} as WasmMessage);
function buildReturn(entry: PoolEntry<T>) {
return {
signalObject: entry.signalObject,
stop: entry.release,
connectionId: entry.connectionId,
readyPromise: entry.readyPromise,
};
}
return buildReturn(entry);
}

@ -0,0 +1,7 @@
import * as NG from "@ng-org/lib-wasm";
export let ng: typeof NG;
export function initNg(ngImpl: typeof NG) {
ng = ngImpl;
}

@ -1,7 +1,7 @@
import type { Diff as Patches, Scope } from "../types.ts";
import { applyDiff } from "./applyDiff.ts";
import * as NG from "@ng-org/lib-wasm";
import { ng } from "./initNg.ts";
import {
deepSignal,
@ -20,7 +20,6 @@ export class OrmConnection<T extends BaseType> {
// TODO: WeakMaps?
private static idToEntry = new Map<string, OrmConnection<any>>();
private ng: typeof NG;
readonly shapeType: ShapeType<T>;
readonly scope: Scope;
readonly signalObject: DeepSignalObject<T | {}>;
@ -45,10 +44,9 @@ export class OrmConnection<T extends BaseType> {
})
: null;
private constructor(shapeType: ShapeType<T>, scope: Scope, ng: typeof NG) {
private constructor(shapeType: ShapeType<T>, scope: Scope) {
this.shapeType = shapeType;
this.scope = scope;
this.ng = ng;
this.refCount = 0;
this.ready = false;
this.suspendDeepWatcher = false;
@ -89,9 +87,10 @@ export class OrmConnection<T extends BaseType> {
*/
public static getConnection<T extends BaseType>(
shapeType: ShapeType<T>,
scope: Scope,
ng: typeof NG
scope: Scope
): OrmConnection<T> {
if (!ng) throw new Error("initNg was not called yet.");
const scopeKey = canonicalScope(scope);
// Unique identifier for a given shape type and scope.
@ -102,7 +101,7 @@ export class OrmConnection<T extends BaseType> {
// Otherwise, create new one.
const connection =
OrmConnection.idToEntry.get(identifier) ??
new OrmConnection(shapeType, scope, ng);
new OrmConnection(shapeType, scope);
connection.refCount += 1;
@ -123,7 +122,7 @@ export class OrmConnection<T extends BaseType> {
const ormPatches = deepPatchesToDiff(patches);
this.ng.orm_update(
ng.orm_update(
this.scope,
this.shapeType.shape,
ormPatches,
@ -139,7 +138,7 @@ export class OrmConnection<T extends BaseType> {
console.log("RESPONSE FROM BACKEND", param);
// TODO: This will break, just provisionary.
const wasmMessage: WasmMessage = param;
const wasmMessage: any = param;
const { initialData } = wasmMessage;
// Assign initial data to empty signal object without triggering watcher at first.

@ -2,9 +2,11 @@ import { createSignalObjectForShape } from "./connector/createSignalObjectForSha
import { useShape as svelteUseShape } from "./frontendAdapters/svelte/index.js";
import { useShape as reactUseShape } from "./frontendAdapters/react/index.js";
import { useShape as vueUseShape } from "./frontendAdapters/vue/useShape.js";
import { initNg } from "./connector/initNg.ts";
export * from "./connector/applyDiff.js";
export {
initNg,
createSignalObjectForShape,
svelteUseShape,
reactUseShape,

Loading…
Cancel
Save