From bb2d9c3e59779a8c51a314e3f6efea13fe5c99ae Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 22 Jun 2026 16:35:40 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20scaffold:=20@ng-eventually/client=20?= =?UTF-8?q?=E2=80=94=20SDK-shaped=20polyfill=20over=20NextGraph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 5 +++ README.md | 50 +++++++++++++++++++++++++++++ bun.lock | 38 ++++++++++++++++++++++ package.json | 15 +++++++++ packages/client/README.md | 32 ++++++++++++++++++ packages/client/package.json | 23 +++++++++++++ packages/client/src/access.ts | 42 ++++++++++++++++++++++++ packages/client/src/inbox.ts | 29 +++++++++++++++++ packages/client/src/index.ts | 21 ++++++++++++ packages/client/src/ng-proxy.ts | 40 +++++++++++++++++++++++ packages/client/src/polyfill.ts | 49 ++++++++++++++++++++++++++++ packages/client/src/types.ts | 39 ++++++++++++++++++++++ packages/client/src/use-shape.ts | 16 +++++++++ packages/client/test/access.test.ts | 30 +++++++++++++++++ packages/client/tsconfig.json | 4 +++ tsconfig.base.json | 15 +++++++++ 16 files changed, 448 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 packages/client/README.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/access.ts create mode 100644 packages/client/src/inbox.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/src/ng-proxy.ts create mode 100644 packages/client/src/polyfill.ts create mode 100644 packages/client/src/types.ts create mode 100644 packages/client/src/use-shape.ts create mode 100644 packages/client/test/access.test.ts create mode 100644 packages/client/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5932e78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store +bun.lockb diff --git a/README.md b/README.md new file mode 100644 index 0000000..351c02f --- /dev/null +++ b/README.md @@ -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. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..57ff2d9 --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6c1f16 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/packages/client/README.md b/packages/client/README.md new file mode 100644 index 0000000..9e04303 --- /dev/null +++ b/packages/client/README.md @@ -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). diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..349beba --- /dev/null +++ b/packages/client/package.json @@ -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" + } +} diff --git a/packages/client/src/access.ts b/packages/client/src/access.ts new file mode 100644 index 0000000..bb00229 --- /dev/null +++ b/packages/client/src/access.ts @@ -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] }; +} diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts new file mode 100644 index 0000000..51e173d --- /dev/null +++ b/packages/client/src/inbox.ts @@ -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 { + 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"); +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..6932a68 --- /dev/null +++ b/packages/client/src/index.ts @@ -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 = makeNg(); diff --git a/packages/client/src/ng-proxy.ts b/packages/client/src/ng-proxy.ts new file mode 100644 index 0000000..384d0d8 --- /dev/null +++ b/packages/client/src/ng-proxy.ts @@ -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 { + return new Proxy({} as Record, { + 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; + }, + }); +} diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts new file mode 100644 index 0000000..2c1590a --- /dev/null +++ b/packages/client/src/polyfill.ts @@ -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"; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts new file mode 100644 index 0000000..e528a86 --- /dev/null +++ b/packages/client/src/types.ts @@ -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; + /** 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; + +/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */ +export type UseShapeLike = (...args: any[]) => any; diff --git a/packages/client/src/use-shape.ts b/packages/client/src/use-shape.ts new file mode 100644 index 0000000..26e6fe8 --- /dev/null +++ b/packages/client/src/use-shape.ts @@ -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; +} diff --git a/packages/client/test/access.test.ts b/packages/client/test/access.test.ts new file mode 100644 index 0000000..6bf63b8 --- /dev/null +++ b/packages/client/test/access.test.ts @@ -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); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..7680f99 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "test"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..f7ad2ac --- /dev/null +++ b/tsconfig.base.json @@ -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 + } +}