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
+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;
}