Initial scaffold: @ng-eventually/client — SDK-shaped polyfill over NextGraph

Generic polyfill layer that makes a single NextGraph broker behave like the
not-yet-shipped multi-user NextGraph (emulated capabilities + inbox). Zero app domain.

@ng-eventually/client exposes an SDK-identical surface (ng, useShape, inbox); the
polyfill bootstrap (configure + capability helpers) is isolated under /polyfill, so
the main entry stays a drop-in for @ng-org/web|orm. The real SDK is injected at
configure() (no hard import → build-alias safe + testable).

Scaffold: NextGraph wiring stubbed with TODO; capability helpers implemented and
unit-tested (4 tests, typecheck clean). The global-index curator is deferred — in
NextGraph apps/services are mono-user with no global data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-06-22 16:35:40 +02:00
commit bb2d9c3e59
16 changed files with 448 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.DS_Store
bun.lockb
+50
View File
@@ -0,0 +1,50 @@
# ng-eventually
A **generic polyfill layer** over the [NextGraph](https://nextgraph.org) JS SDK.
NextGraph's JS SDK does not yet expose cross-wallet reads, capabilities, inboxes
or group stores. `ng-eventually` lets an app behave as if those existed today, by
emulating them on top of a **single shared wallet / broker**. It is **generic**:
it contains **no application domain** — the consumer injects its shapes and the
*acts* of granting access.
The name: *eventually* NextGraph will ship these features; until then this layer
fills the gap (and nods at eventual consistency / events).
## Packages
| Package | Role | At migration |
|---|---|---|
| **`@ng-eventually/client`** | **SDK-identical** wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. Adds the polyfills the broker/verifier will do natively (shared-wallet login, capability enforcement, anticipated cap/inbox methods). | Disappears: the app points back at the real SDK (build alias removed). |
> **A global-index curator package is deferred.** NextGraph is **mono-user with
> no global data** (apps/services see only what the user shares; there is no
> multi-user backend). A global index would come from a **singleton app**
> (a global document administered by the developer) — **not implemented and
> uncertain**, and simpler paths may exist. So no second package for now; it will
> be (re)introduced once the global-index mechanism is decided. The curator must
> never be bundled in the client → it will be a separate package when it lands.
## Design principle
The application code is written **as if the target NextGraph existed**. All
compensation lives here, *beside* the app. Migration = remove this layer; the
app code (SDK-shaped) is unchanged.
- **SDK-identical surface**: the client wraps the real `ng` (a Proxy that
forwards everything and overrides only what must be emulated) and `useShape`.
The real SDK is **injected** via `configure()` (no hard import → build-alias
safe + testable).
- **Authorization = emulated capabilities**: documents carry grants; the client
enforces them generically (read filter + write guard). The app *attaches*
grants via cap operations — same as it will in the target. No policy is
injected.
- **Inbox**: the client `inbox.post()` deposits; materialization (the curator)
is deferred with the global-index mechanism — see the note above.
- **Tests** of the polyfill (against a real broker) live **in this repo**, so
the consuming app can test its features against a mocked, clean API.
## Status
Scaffold. Mechanisms are stubbed with `TODO(broker)` / `TODO(polyfill)` where
real NextGraph wiring is required.
+38
View File
@@ -0,0 +1,38 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "ng-eventually",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0",
},
},
"packages/client": {
"name": "@ng-eventually/client",
"version": "0.0.0",
"peerDependencies": {
"@ng-org/orm": "*",
"@ng-org/web": "*",
},
"optionalPeers": [
"@ng-org/orm",
"@ng-org/web",
],
},
},
"packages": {
"@ng-eventually/client": ["@ng-eventually/client@workspace:packages/client"],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "ng-eventually",
"private": true,
"type": "module",
"description": "Generic polyfill layer over the NextGraph JS SDK — makes a single broker behave like the (not-yet-shipped) multi-user NextGraph: emulated capabilities and inbox. Drop-in SDK-shaped client; remove at migration. (A global-index curator package is deferred — see README.)",
"workspaces": ["packages/*"],
"scripts": {
"test": "bun test",
"typecheck": "bunx tsc --noEmit -p packages/client/tsconfig.json"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
# @ng-eventually/client
Two entry points — the data-plane is **SDK-identical**, the polyfill bootstrap is
separate:
| Import | Surface | At migration |
|---|---|---|
| `@ng-eventually/client` | **Same signature as the SDK**`ng`, `useShape`, `inbox` (+ types). Drop-in for `@ng-org/web` / `@ng-org/orm`. | Resolves to the real SDK (build alias removed) — no code change. |
| `@ng-eventually/client/polyfill` | The **only non-SDK** surface — `configure`, `setCurrentUser`, capability helpers (`canRead`/`canWrite`/`defaultGrant`/`grantRead`). | Removed. |
```ts
// bootstrap (the only non-SDK call) — inject the REAL SDK
import { configure } from "@ng-eventually/client/polyfill";
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser });
// from here on, pure SDK surface:
import { ng, useShape, inbox } from "@ng-eventually/client";
await ng.doc_create(/* … */);
const set = useShape(MyShape, scope); // filtered to what the user may read
await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
```
What the polyfill adds on top of the real SDK (all removed/native at migration):
- **Shared-wallet login** (one wallet for everyone).
- **Capability enforcement** — read filter + write guard, on emulated grants
attached to documents.
- **Anticipated methods** (inbox `post`, capability ops) with their future-SDK
shapes, emulated for now.
Generic: **no application domain**. The consumer injects shapes and performs the
*acts* of granting access. The client **must not** contain the global-index
curator (a separate package, deferred — see the repo README).
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@ng-eventually/client",
"version": "0.0.0",
"type": "module",
"description": "SDK-identical client wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./polyfill": "./src/polyfill.ts"
},
"peerDependencies": {
"@ng-org/web": "*",
"@ng-org/orm": "*"
},
"peerDependenciesMeta": {
"@ng-org/web": { "optional": true },
"@ng-org/orm": { "optional": true }
},
"scripts": {
"test": "bun test"
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Capability emulation — pure, generic, no domain rules. Mirrors what the broker
* will enforce via real caps; at migration these checks disappear (the broker
* only ever syncs/serves what the wallet is authorized for).
*/
import type { Grant, PrincipalId, Scope } from "./types";
/** May `principal` read a document carrying `grant`? */
export function canRead(grant: Grant, principal: PrincipalId | null): boolean {
if (grant.read.includes("public")) return true;
if (principal === null) return false;
return grant.read.includes(principal);
}
/** May `principal` write a document carrying `grant`? */
export function canWrite(grant: Grant, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return grant.write.includes(principal);
}
/**
* The grant a freshly created document gets, by scope, for its `owner`. These
* are the *defaults* a creator attaches; further sharing (e.g. granting a
* connection read on a protected doc) is a separate explicit act.
*/
export function defaultGrant(scope: Scope, owner: PrincipalId): Grant {
switch (scope) {
case "public":
return { read: ["public"], write: [owner] };
case "protected":
return { read: [owner], write: [owner] };
case "private":
return { read: [owner], write: [owner] };
}
}
/** Add a read grant to a principal (e.g. when a connection is accepted). */
export function grantRead(grant: Grant, principal: PrincipalId): Grant {
if (grant.read.includes(principal)) return grant;
return { ...grant, read: [...grant.read, principal] };
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Inbox — client side (deposit only). The MATERIALIZATION of deposits is the
* curator's job (a separate package, deferred — see repo README), never the client's.
*/
import { getConfig, getCurrentUser } from "./polyfill";
import type { Nuri } from "./types";
/**
* Deposit a reference into a document's inbox (anticipated SDK shape). Target:
* `ng.inbox_post_link(...)`, sealed to the inbox owner; `from` optional → the
* sender is identified if known, anonymous otherwise. Polyfill: append a
* deposit to the emulated inbox document.
*/
export async function post(
targetInbox: Nuri,
payload: unknown,
opts?: { anonymous?: boolean },
): Promise<void> {
const { ng } = getConfig();
const from = opts?.anonymous ? null : getCurrentUser();
// TODO(polyfill): append { from, payload } to the emulated inbox document
// (targetInbox) via ng.sparql_update.
void ng;
void from;
void targetInbox;
void payload;
throw new Error("[ng-eventually] inbox.post: polyfill emulation not yet implemented");
}
+21
View File
@@ -0,0 +1,21 @@
/**
* @ng-eventually/client — **SDK-identical** surface.
*
* This entry exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose (current +
* anticipated: `inbox`). Import `ng` / `useShape` from here instead of the SDK
* during the polyfill period; at migration the build alias is removed and these
* resolve to the real SDK with **no code change**.
*
* The one non-SDK piece — the polyfill bootstrap (`configure`, capability
* helpers, current user) — lives at `@ng-eventually/client/polyfill`, and is the
* only thing removed at migration.
*/
export * from "./types";
export { useShape } from "./use-shape";
export * as inbox from "./inbox";
import { makeNg } from "./ng-proxy";
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
export const ng: Record<string, any> = makeNg();
+40
View File
@@ -0,0 +1,40 @@
/**
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
* overrides only what the broker/verifier will do natively at migration. The
* surface stays identical to `@ng-org/web`'s `ng`.
*/
import { getConfig } from "./polyfill";
export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
const { ng } = getConfig();
// login / session_start → open the SHARED wallet invisibly.
if (prop === "login" || prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
}
// sparql_update → write guard (emulated write-cap check).
if (prop === "sparql_update") {
return (...args: any[]) => {
// TODO(polyfill): reject when getCurrentUser() lacks the write grant
// on the target document (see access.canWrite). For now, passthrough.
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): inbox_post_link + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop];
return typeof real === "function" ? real.bind(ng) : real;
},
});
}
+49
View File
@@ -0,0 +1,49 @@
/**
* The polyfill bootstrap — the ONLY non-SDK surface of the client.
*
* It injects the REAL SDK and the polyfill settings; afterwards the SDK-shaped
* exports (`ng`, `useShape`, `inbox`) behave as drop-ins. This is exposed at the
* subpath `@ng-eventually/client/polyfill` so the main entry
* (`@ng-eventually/client`) stays a **pure, SDK-identical** surface. Everything
* here is removed at migration.
*/
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/** Shared-wallet credentials — polyfill only (one wallet for everyone). */
sharedWallet?: { name: string; secret: string };
/** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId;
}
let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null;
export function configure(c: EventuallyConfig): void {
cfg = c;
currentUser = c.currentUser ?? null;
}
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
export function getConfig(): EventuallyConfig {
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
return cfg;
}
/** Set the current app-level user (the polyfill's notion of "who am I"). */
export function setCurrentUser(id: PrincipalId | null): void {
currentUser = id;
}
export function getCurrentUser(): PrincipalId | null {
return currentUser;
}
// Capability helpers — polyfill-era surface (caps are emulated now; native at
// migration). Re-exported here so the whole polyfill API lives under /polyfill.
export { canRead, canWrite, defaultGrant, grantRead } from "./access";
+39
View File
@@ -0,0 +1,39 @@
/**
* Generic, NextGraph-shaped types. ZERO application domain.
*/
/** A NextGraph URI (document / store / inbox). */
export type Nuri = string;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */
export type Scope = "public" | "protected" | "private";
/** App-level current identity. Target: the wallet user (`session.user`).
* Polyfill: a chosen id (e.g. a username), because everyone shares one wallet. */
export type PrincipalId = string;
/**
* Emulated capability attached to a document — mirrors real NextGraph caps,
* which the broker/verifier will enforce natively at migration. The app attaches
* these via cap operations exactly as it will in the target; this layer enforces
* them generically (it does NOT know any authorization policy).
*/
export interface Grant {
/** Read grant: `"public"` = everyone, otherwise explicit principals. */
read: Array<PrincipalId | "public">;
/** Write grant: principals allowed to write the document. */
write: PrincipalId[];
}
/**
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
* the consumer at {@link configure} — we never hard-import the SDK, which keeps
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
* carries non-function members too, so we accept any property bag.
*/
export type NgLike = Record<string, any>;
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
export type UseShapeLike = (...args: any[]) => any;
+16
View File
@@ -0,0 +1,16 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. The returned reactive set
* is filtered to what the current user may read (emulated caps). At migration
* this filtering disappears — the broker only syncs authorized documents.
*/
import { getConfig } from "./polyfill";
export function useShape(shapeType: unknown, scope: unknown): unknown {
const { useShape: real } = getConfig();
const set = real(shapeType, scope);
// TODO(polyfill): wrap `set` so iteration yields only entries whose emulated
// grant satisfies access.canRead(grant, getCurrentUser()), while preserving
// the reactivity of the underlying DeepSignalSet. This is the trickiest piece.
return set;
}
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from "bun:test";
import { canRead, canWrite, defaultGrant, grantRead } from "../src/access";
test("public docs are readable by anyone, even anonymous", () => {
const g = defaultGrant("public", "alice");
expect(canRead(g, null)).toBe(true);
expect(canRead(g, "bob")).toBe(true);
});
test("protected docs: owner + explicitly granted principals only", () => {
let g = defaultGrant("protected", "alice");
expect(canRead(g, "alice")).toBe(true);
expect(canRead(g, "bob")).toBe(false);
g = grantRead(g, "bob"); // e.g. bob becomes a connection of alice
expect(canRead(g, "bob")).toBe(true);
});
test("private docs: owner only", () => {
const g = defaultGrant("private", "alice");
expect(canRead(g, "alice")).toBe(true);
expect(canRead(g, "bob")).toBe(false);
expect(canRead(g, null)).toBe(false);
});
test("write is restricted to the write grant", () => {
const g = defaultGrant("public", "alice");
expect(canWrite(g, "alice")).toBe(true);
expect(canWrite(g, "bob")).toBe(false);
expect(canWrite(g, null)).toBe(false);
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUncheckedIndexedAccess": true,
"declaration": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noEmit": true
}
}