Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70de7afa3c | |||
| 3547967d37 | |||
| 5e91771da6 | |||
| 5cdc6ce77f | |||
| 3046ead08f | |||
| 078d675bbf | |||
| 9103996dbe | |||
| bd48b16e31 | |||
| 7c233df5c0 | |||
| 38b152136b |
@@ -43,10 +43,13 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
|||||||
resolvers) is designed to survive that swap unchanged.
|
resolvers) is designed to survive that swap unchanged.
|
||||||
|
|
||||||
### 3. Drop the resolver / shim
|
### 3. Drop the resolver / shim
|
||||||
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
|
The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribable
|
||||||
has no target equivalent — the target has no central directory. Remove it:
|
doc-shim reached via a write-once pointer in the store-root — see
|
||||||
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||||
replace the fan-out; per-user wallets replace the shared one.
|
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||||
|
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
||||||
|
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||||
|
per-user wallets replace the shared one.
|
||||||
|
|
||||||
### 4. Real inbox → drop the in-lib read emulation
|
### 4. Real inbox → drop the in-lib read emulation
|
||||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||||
|
|||||||
@@ -217,6 +217,74 @@ broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires
|
|||||||
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
repo's NURI + ReadCap — there is no store-level read inheritance (see
|
||||||
§ Capability / ReadCap granularity).
|
§ Capability / ReadCap granularity).
|
||||||
|
|
||||||
|
### Findable-without-lookup vs subscribable (first-`State` barrier) — DISJOINT
|
||||||
|
|
||||||
|
Two properties a fresh session might want from a document, and **no single document
|
||||||
|
has both**:
|
||||||
|
|
||||||
|
- **Findable without a lookup.** The ONLY NURI a fresh session can NAME with nothing
|
||||||
|
but the session in hand is the store-root, `did:ng:${privateStoreId}` (from
|
||||||
|
`session.private_store_id`). Any per-document repo is `did:ng:o:<RepoID>` with a
|
||||||
|
**random** RepoID minted by `doc_create` — **not derivable**, so it must be looked
|
||||||
|
up somewhere first.
|
||||||
|
- **Subscribable with a sync BARRIER.** `doc_subscribe(nuri)` delivers `TabInfo` then
|
||||||
|
an initial **`State`** (`verifier.rs:470`/`:476`); that first `State` is the sync
|
||||||
|
barrier — **after it, presence is guaranteed and absence is definitive** (pinned
|
||||||
|
empirically by CONTRACT 3 in `packages/client/e2e/`). But this barrier exists only
|
||||||
|
for a repo `doc_subscribe` can open, i.e. a `did:ng:o:<RepoID>` repo. A **store-root
|
||||||
|
has no first-`State` barrier**: an anchored read on it can return 0 rows during
|
||||||
|
sync-lag with no signal distinguishing "still syncing" from "genuinely empty".
|
||||||
|
|
||||||
|
These two are **mutually exclusive**: the guessable target (store-root) is not
|
||||||
|
barrier-authoritative, and the barrier-authoritative target (`o:` repo) is not
|
||||||
|
guessable. **Consequence:** you cannot build a lookup table that is BOTH reachable
|
||||||
|
cold (findable) AND authoritative on a cold read (barrier). A cold "0 rows" read of a
|
||||||
|
store-root graph is therefore fundamentally ambiguous — which is the trap the shim's
|
||||||
|
account map fell into (see next section).
|
||||||
|
|
||||||
|
### The pointer → doc-shim indirection (how the polyfill shim resolves accounts)
|
||||||
|
|
||||||
|
`store-registry.ts` keeps a map `identifier → {docPublic, docProtected, docPrivate}`
|
||||||
|
(the "shim", the account→document trust root). It must be reachable by a fresh
|
||||||
|
reconnecting session (findable) AND authoritative on a cold read (so a fresh page
|
||||||
|
does not mistake sync-lag for "account absent" and PROVISION a fork). Since no single
|
||||||
|
document is both (previous section), the shim uses an **indirection**:
|
||||||
|
|
||||||
|
1. **doc-shim** — a `doc_create`d graph document (`did:ng:o:...`, hence a first-`State`
|
||||||
|
barrier). **All `AccountRecord`s live inside it.** Because it is subscribable, an
|
||||||
|
anchored read behind its `ensureRepoOpen` barrier is **authoritative**: a cold 0
|
||||||
|
means the account is genuinely absent.
|
||||||
|
2. **pointer** — a single well-known, **write-once** triple in the store-root graph,
|
||||||
|
`<urn:ng-eventually:shim:root> <urn:ng-eventually:shim:shimDoc> <docShimNuri>`.
|
||||||
|
The store-root is findable-without-lookup, so a fresh session can always read it;
|
||||||
|
the pointer being the OLDEST, write-once triple in that graph, it is near-always
|
||||||
|
already synced on a cold read.
|
||||||
|
|
||||||
|
**Resolution** (`resolveShimDoc`): read the pointer from the store-root → open the
|
||||||
|
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
||||||
|
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
|
||||||
|
pointer, done. A **pointer fork** (two devices each writing a pointer before either
|
||||||
|
synced) is reconciled to the lexicographically-smallest doc-shim NURI
|
||||||
|
(content-addressed, so every device converges on the same doc-shim).
|
||||||
|
|
||||||
|
**The account-level retry is GONE.** Before this indirection the shim lived directly
|
||||||
|
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
|
||||||
|
the lib compensated with a **bounded account-level retry** (`provisionRetry` /
|
||||||
|
`resolveAccountReliably`) that re-read the account several times before concluding
|
||||||
|
"new". Moving the records behind the doc-shim barrier makes the account read
|
||||||
|
authoritative on the FIRST read, so **that retry was removed** — a barrier is
|
||||||
|
deterministic where a retry only guessed. The only residual bounded guard is a small
|
||||||
|
re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it can
|
||||||
|
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
||||||
|
a pointer that is still landing.
|
||||||
|
|
||||||
|
**No legacy migration.** A wallet written under the OLD scheme (accounts directly in
|
||||||
|
the store-root graph, no pointer) is NOT recovered: opening it under the current scheme
|
||||||
|
simply provisions a fresh doc-shim, and the pre-indirection store-root records are
|
||||||
|
ignored. This is deliberate — the only such wallets are dev data — so the resolution
|
||||||
|
path carries no legacy-migration step; it always reads the account authoritatively from
|
||||||
|
the doc-shim.
|
||||||
|
|
||||||
### The union is read-only — writes must target one document
|
### The union is read-only — writes must target one document
|
||||||
|
|
||||||
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
`resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
|
||||||
|
|||||||
+17
-6
@@ -98,12 +98,23 @@ public/protected/private stores — on top of one shared wallet.
|
|||||||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||||||
logical attribute tracked here, not a physical store.
|
logical attribute tracked here, not a physical store.
|
||||||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||||||
NURIs`, persisted as RDF in the shared wallet's private store (the anchor,
|
NURIs`. It is persisted as RDF, but **not directly in the store-root graph** — it
|
||||||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
lives in a subscribable **doc-shim** reached through a write-once **pointer** in the
|
||||||
identity resolution cross-device: another device opening the same wallet reads
|
store-root, an indirection forced by a NextGraph fact: "findable-without-lookup"
|
||||||
the same shim and finds the same accounts. It is the account→document trust root,
|
(store-root) and "subscribable / cold-read-authoritative" (`did:ng:o:` repo with a
|
||||||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
first-`State` barrier) are DISJOINT. The pointer (findable) names the doc-shim
|
||||||
SPARQL hardening below).
|
(authoritative); resolution reads the pointer from the store-root, opens the doc-shim
|
||||||
|
through its barrier, and reads the account authoritatively — so a fresh reconnecting
|
||||||
|
session never mistakes sync-lag for "account absent" (which would provision a FORK).
|
||||||
|
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
||||||
|
removed (pre-indirection store-root records are NOT recovered; such wallets are dev
|
||||||
|
data and simply get a fresh doc-shim) — is in
|
||||||
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
|
||||||
|
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
||||||
|
account→document trust root, which is why every untrusted value that reaches its
|
||||||
|
SPARQL is escaped (see SPARQL hardening below). It makes identity resolution
|
||||||
|
cross-device: another device opening the same wallet reads the same pointer → the
|
||||||
|
same doc-shim → the same accounts.
|
||||||
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
- **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
|
||||||
makes a dedicated document for one entity (mirrors the target, where each entity
|
makes a dedicated document for one entity (mirrors the target, where each entity
|
||||||
is its own document/repo with a future inbox) and appends its NURI to the
|
is its own document/repo with a future inbox) and appends its NURI to the
|
||||||
|
|||||||
@@ -140,6 +140,72 @@ export async function launchWalletContext(): Promise<BrowserContext> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
|
||||||
|
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
|
||||||
|
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
|
||||||
|
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
|
||||||
|
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
|
||||||
|
*
|
||||||
|
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
|
||||||
|
* but the context stays OPEN and is returned (with its dir) so the caller can then
|
||||||
|
* open the SDK page in the SAME profile — i.e. the very first app session over a
|
||||||
|
* wallet that has never run the app. Caller cleans up ctx + dir.
|
||||||
|
*/
|
||||||
|
export async function createFreshWalletContext(): Promise<{
|
||||||
|
ctx: BrowserContext;
|
||||||
|
dir: string;
|
||||||
|
name: string;
|
||||||
|
}> {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
|
||||||
|
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||||
|
const ctx = await chromium.launchPersistentContext(dir, {
|
||||||
|
headless: true,
|
||||||
|
executablePath: resolveChromePath(),
|
||||||
|
args: LAUNCH_ARGS,
|
||||||
|
});
|
||||||
|
const page = ctx.pages()[0] || (await ctx.newPage());
|
||||||
|
page.on("pageerror", () => {});
|
||||||
|
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||||
|
const createButton = page.getByText("Create Wallet", { exact: true });
|
||||||
|
await createButton.waitFor({ state: "visible", timeout: 15000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
|
||||||
|
const acceptButton = page.getByText("I accept", { exact: true });
|
||||||
|
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
|
||||||
|
await acceptButton.click();
|
||||||
|
|
||||||
|
const usernameInput = page.locator("#username-input");
|
||||||
|
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
|
||||||
|
await usernameInput.fill(name);
|
||||||
|
const passwordInput = page.locator("#password-input");
|
||||||
|
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
await passwordInput.fill(WALLET_PASSWORD);
|
||||||
|
|
||||||
|
const submitButton = page.getByText("create my wallet", { exact: false });
|
||||||
|
await submitButton.waitFor({ state: "visible", timeout: 5000 });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// First login → bootstrap the verifier repos from the broker (this is what a
|
||||||
|
// brand-new wallet does on its very first unlock).
|
||||||
|
const walletLink = page.getByText("Click here to login with your wallet");
|
||||||
|
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||||
|
await walletLink.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
const loginPassword = page.locator('input[type="password"]');
|
||||||
|
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
|
||||||
|
await loginPassword.fill(WALLET_PASSWORD);
|
||||||
|
await loginPassword.press("Enter");
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
return { ctx, dir, name };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
|
||||||
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
|
||||||
|
* shim's first account resolution/provision.
|
||||||
|
*
|
||||||
|
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
|
||||||
|
* private-store repo is already in the verifier's `self.repos`), this mints a
|
||||||
|
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
|
||||||
|
* very first session. It then, in order:
|
||||||
|
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
|
||||||
|
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
|
||||||
|
* yet open) rather than a silent 0 rows;
|
||||||
|
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
|
||||||
|
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
|
||||||
|
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
|
||||||
|
* the SAME docs (real persistence in the shim).
|
||||||
|
*
|
||||||
|
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
|
||||||
|
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
|
||||||
|
* succeed because ensureAccount opens the anchor repo before read/write.
|
||||||
|
*
|
||||||
|
* Run: `bun run e2e/repro-fresh-wallet.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import type { Frame, Page, BrowserContext } from "playwright";
|
||||||
|
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
|
||||||
|
|
||||||
|
type Check = { name: string; ok: boolean; detail?: string };
|
||||||
|
const results: Check[] = [];
|
||||||
|
function check(name: string, ok: boolean, detail?: string): void {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
|
||||||
|
return frame.evaluate(
|
||||||
|
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
|
||||||
|
[method, args] as const,
|
||||||
|
) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log("[repro] building SDK page bundle...");
|
||||||
|
buildBundle();
|
||||||
|
const { url, close: closeServer } = await serveHarness();
|
||||||
|
console.log(`[repro] harness served at ${url}`);
|
||||||
|
|
||||||
|
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
|
||||||
|
let ctx: BrowserContext | null = null;
|
||||||
|
let dir: string | null = null;
|
||||||
|
let page: Page | null = null;
|
||||||
|
try {
|
||||||
|
const fresh = await createFreshWalletContext();
|
||||||
|
ctx = fresh.ctx;
|
||||||
|
dir = fresh.dir;
|
||||||
|
console.log(`[repro] fresh wallet: ${fresh.name}`);
|
||||||
|
|
||||||
|
page = await ctx.newPage();
|
||||||
|
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
|
||||||
|
page.on("console", (m) => {
|
||||||
|
if (m.type() === "error") console.error("[iframe console]", m.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
|
||||||
|
const frame = await setupBrokerPage(page, url);
|
||||||
|
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
|
||||||
|
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
console.log("[repro] connected. Driving the cold-start shim resolution...");
|
||||||
|
|
||||||
|
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
|
||||||
|
// private-store repo is not yet open in this fresh session.
|
||||||
|
const probe = await sdk<any>(frame, "shimAnchorProbe");
|
||||||
|
console.log(
|
||||||
|
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
|
||||||
|
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
|
||||||
|
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
|
||||||
|
check(
|
||||||
|
"fresh wallet: ensureAccount provisions the account without throwing",
|
||||||
|
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
|
||||||
|
ensured.threw
|
||||||
|
? `THREW: ${ensured.error}`
|
||||||
|
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
|
||||||
|
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
|
||||||
|
check(
|
||||||
|
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
|
||||||
|
!verified.threw &&
|
||||||
|
verified.docPublic === ensured.docPublic &&
|
||||||
|
verified.docProtected === ensured.docProtected &&
|
||||||
|
verified.docPrivate === ensured.docPrivate,
|
||||||
|
verified.threw
|
||||||
|
? `THREW: ${verified.error}`
|
||||||
|
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try { if (page) await page.close(); } catch { /* ignore */ }
|
||||||
|
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
|
||||||
|
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
closeServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = results.filter((r) => r.ok).length;
|
||||||
|
const failed = results.length - passed;
|
||||||
|
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
|
||||||
|
if (failed > 0) {
|
||||||
|
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("[repro] fatal:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -294,6 +294,47 @@ async function main(): Promise<void> {
|
|||||||
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}…`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
|
||||||
|
// The real cycle: first subscription reads isPending (barrier not yet crossed),
|
||||||
|
// then after the broker sync it reaches isSuccess with the seeded datum present.
|
||||||
|
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
|
||||||
|
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
|
||||||
|
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
|
||||||
|
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
|
||||||
|
const h = "cyc" + Date.now();
|
||||||
|
const CLS = "urn:e2e:ws:Event";
|
||||||
|
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
|
||||||
|
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
|
||||||
|
// Event-driven: wait for the barrier to cross + the datum to land.
|
||||||
|
await frame.waitForFunction(
|
||||||
|
(hh) => {
|
||||||
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||||
|
return s && s.isSuccess && s.dataLen >= 1;
|
||||||
|
},
|
||||||
|
h,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||||
|
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
|
||||||
|
await sdk(frame, "watchShapeStop", h);
|
||||||
|
});
|
||||||
|
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
|
||||||
|
const h = "empty" + Date.now();
|
||||||
|
const CLS = "urn:e2e:ws:Event";
|
||||||
|
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
|
||||||
|
await frame.waitForFunction(
|
||||||
|
(hh) => {
|
||||||
|
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
|
||||||
|
return s && s.isSuccess;
|
||||||
|
},
|
||||||
|
h,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
|
||||||
|
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
|
||||||
|
await sdk(frame, "watchShapeStop", h);
|
||||||
|
});
|
||||||
|
|
||||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||||
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
||||||
await step("read-filter: protected hidden from stranger", async () => {
|
await step("read-filter: protected hidden from stranger", async () => {
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import {
|
|||||||
discovery,
|
discovery,
|
||||||
storeRegistry,
|
storeRegistry,
|
||||||
useShape as libUseShape,
|
useShape as libUseShape,
|
||||||
|
watchShape,
|
||||||
accounts,
|
accounts,
|
||||||
} from "@ng-eventually/client";
|
} from "@ng-eventually/client";
|
||||||
|
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||||
|
|
||||||
const { IdentityStore } = accounts;
|
const { IdentityStore } = accounts;
|
||||||
|
|
||||||
@@ -84,6 +86,13 @@ configureStoreRegistry({
|
|||||||
},
|
},
|
||||||
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
// Identity normalization used as the shim key (lowercase, strip leading `@`).
|
||||||
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
|
||||||
|
// REAL broker: enable the POINTER micro-guard. The account records live in a
|
||||||
|
// subscribable doc-shim reached via a write-once pointer triple in the store-root;
|
||||||
|
// the account read is barrier-authoritative (no account retry). The only residual
|
||||||
|
// store-root sync-lag is the pointer read — this bounded guard re-reads JUST that
|
||||||
|
// one write-once triple on a cold reconnect (CONTRACT 2 non-fork). It can never
|
||||||
|
// provision or fork an account.
|
||||||
|
pointerGuard: { attempts: 8, baseMs: 150, maxStepMs: 2000 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
// ── Bridge marshaling note ─────────────────────────────────────────────────
|
||||||
@@ -563,6 +572,184 @@ const identity = new IdentityStore(
|
|||||||
return { priv, prot, pub };
|
return { priv, prot, pub };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COLD-START anchor probe. Runs the EXACT shim SELECT the registry issues,
|
||||||
|
* anchored to `did:ng:${private_store_id}` (the shim anchor), as the very first
|
||||||
|
* thing in a fresh session — BEFORE anything opens that repo. Reports whether the
|
||||||
|
* raw anchored query threw `RepoNotFound` (the cold-start bug: the private-store
|
||||||
|
* repo not yet in `self.repos`) or returned rows. Uses the low-level docs
|
||||||
|
* primitive directly so nothing (open-repo, ensureAccount) opens the repo first.
|
||||||
|
*/
|
||||||
|
async shimAnchorProbe() {
|
||||||
|
const s = session ?? (await sessionReady);
|
||||||
|
const anchor = `did:ng:${s.private_store_id}`;
|
||||||
|
const query =
|
||||||
|
"SELECT ?acc WHERE { GRAPH <" +
|
||||||
|
anchor +
|
||||||
|
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
|
||||||
|
try {
|
||||||
|
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
||||||
|
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
|
||||||
|
return { threw: false, error: null, rows, anchor };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), rows: -1, anchor };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* COLD-START account provision. resetRegistryCache then ensureAccount(id) — the
|
||||||
|
* real bootstrap the app runs on first login. On a fresh wallet, if the anchor
|
||||||
|
* repo isn't open, resolveAccount's read AND ensureAccount's provision write both
|
||||||
|
* hit RepoNotFound; the account never persists. Returns the 3 scope docs (all
|
||||||
|
* truthy iff provisioning succeeded) so the runner can gate on real persistence.
|
||||||
|
*/
|
||||||
|
async coldEnsureAccount(id: string) {
|
||||||
|
storeRegistry.resetRegistryCache();
|
||||||
|
try {
|
||||||
|
const rec = await storeRegistry.ensureAccount(id);
|
||||||
|
return {
|
||||||
|
threw: false,
|
||||||
|
error: null,
|
||||||
|
docPublic: rec.docPublic,
|
||||||
|
docProtected: rec.docProtected,
|
||||||
|
docPrivate: rec.docPrivate,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* VERIFY the provisioned account actually PERSISTED to the shim: resetRegistryCache
|
||||||
|
* then re-resolve the SAME id via a fresh anchored read. Returns whether the read
|
||||||
|
* threw + the resolved docs. After the fix, on a fresh wallet this returns the SAME
|
||||||
|
* docs coldEnsureAccount minted (real persistence, no RepoNotFound).
|
||||||
|
*/
|
||||||
|
async verifyShimPersisted(id: string) {
|
||||||
|
storeRegistry.resetRegistryCache();
|
||||||
|
try {
|
||||||
|
const rec = await storeRegistry.ensureAccount(id);
|
||||||
|
return { threw: false, error: null, docPublic: rec.docPublic, docProtected: rec.docProtected, docPrivate: rec.docPrivate };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { threw: true, error: String(e?.message ?? e), docPublic: "", docProtected: "", docPrivate: "" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── watchShape (reactive useQuery-shaped read) ───────────────────────────
|
||||||
|
// A minimal SHEX-ish ShapeType pinning rdf:type to an e2e class IRI. watchShape
|
||||||
|
// reads only `.shape`/`.schema[...].predicates[rdf:type].dataTypes[].literals`
|
||||||
|
// to know which @type to keep — generic, no application domain.
|
||||||
|
_wsProbes: {} as Record<
|
||||||
|
string,
|
||||||
|
{ obs: ShapeObservable; initial: ShapeQuery; unsub: () => void }
|
||||||
|
>,
|
||||||
|
/**
|
||||||
|
* REAL-BROKER cycle proof. Under a fresh identity, create ONE protected entity
|
||||||
|
* document carrying an rdf:type=<e2e class> triple, then open a `watchShape` over
|
||||||
|
* (that class shape, "protected"). Capture the FIRST snapshot right after subscribe
|
||||||
|
* (must be isPending) so the caller can then wait event-driven for isSuccess with
|
||||||
|
* the seeded datum present. Returns the handle + the initial snapshot + the seed
|
||||||
|
* doc/type so the runner can assert the data landed.
|
||||||
|
*/
|
||||||
|
async watchShapeSeedAndSubscribe(handle: string, cls: string) {
|
||||||
|
storeRegistry.resetRegistryCache();
|
||||||
|
const id = "@ws-" + handle;
|
||||||
|
setCurrentUser(id);
|
||||||
|
const doc = await storeRegistry.createEntityDoc(id, "protected");
|
||||||
|
const s = await sessionReady;
|
||||||
|
// Seed the entity doc with the shape's type + a title (anchored default graph).
|
||||||
|
await docs.sparqlUpdate(
|
||||||
|
s.session_id,
|
||||||
|
`INSERT DATA { <${doc}> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <${cls}> ; <urn:e2e:ws:title> "seeded" }`,
|
||||||
|
doc,
|
||||||
|
);
|
||||||
|
// Wait until this session sees the index append (data persisted on the broker).
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
storeRegistry.resetRegistryCache();
|
||||||
|
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
|
||||||
|
if (listed.includes(doc)) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
const shape = {
|
||||||
|
shape: "urn:e2e:ws:Shape",
|
||||||
|
schema: {
|
||||||
|
"urn:e2e:ws:Shape": {
|
||||||
|
iri: "urn:e2e:ws:Shape",
|
||||||
|
predicates: [
|
||||||
|
{
|
||||||
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||||
|
readablePredicate: "type",
|
||||||
|
maxCardinality: 1,
|
||||||
|
minCardinality: 1,
|
||||||
|
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
// First snapshot immediately after subscribe — the barrier is not yet crossed
|
||||||
|
// (docs opening via doc_subscribe), so this MUST be pending.
|
||||||
|
const initial = obs.getSnapshot();
|
||||||
|
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||||
|
// NB: leave the current user SET for the probe's lifetime — watchShape resolves
|
||||||
|
// the scope from getCurrentUser() on every (reactive) refresh, exactly as the app
|
||||||
|
// keeps a stable identity. watchShapeStop clears it.
|
||||||
|
return { doc, cls, initial: { isPending: initial.isPending, isSuccess: initial.isSuccess, dataLen: initial.data.length } };
|
||||||
|
},
|
||||||
|
/** Current snapshot of a watchShape probe (event-driven poll target). */
|
||||||
|
watchShapeSnapshot(handle: string) {
|
||||||
|
const rec = (window as any).__sdk._wsProbes[handle];
|
||||||
|
if (!rec) return null;
|
||||||
|
const snap: ShapeQuery = rec.obs.getSnapshot();
|
||||||
|
return {
|
||||||
|
isPending: snap.isPending,
|
||||||
|
isSuccess: snap.isSuccess,
|
||||||
|
isError: snap.isError,
|
||||||
|
dataLen: snap.data.length,
|
||||||
|
// The seeded title, if the datum is present (proves the real data landed).
|
||||||
|
titles: snap.data.flatMap((d: any) => d.props?.["urn:e2e:ws:title"] ?? []),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watchShapeStop(handle: string) {
|
||||||
|
const rec = (window as any).__sdk._wsProbes[handle];
|
||||||
|
if (rec) rec.unsub();
|
||||||
|
setCurrentUser(null);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* EMPTY-scope proof: a brand-new identity with NO entity docs of `scope`. watchShape
|
||||||
|
* must reach isSuccess with data:[] (synced-but-empty), NOT stay pending. Returns
|
||||||
|
* the handle; poll watchShapeSnapshot for the transition.
|
||||||
|
*/
|
||||||
|
watchShapeEmptyStart(handle: string, cls: string) {
|
||||||
|
storeRegistry.resetRegistryCache();
|
||||||
|
const id = "@ws-empty-" + handle;
|
||||||
|
setCurrentUser(id);
|
||||||
|
const shape = {
|
||||||
|
shape: "urn:e2e:ws:Shape",
|
||||||
|
schema: {
|
||||||
|
"urn:e2e:ws:Shape": {
|
||||||
|
iri: "urn:e2e:ws:Shape",
|
||||||
|
predicates: [
|
||||||
|
{
|
||||||
|
iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
|
||||||
|
readablePredicate: "type",
|
||||||
|
maxCardinality: 1,
|
||||||
|
minCardinality: 1,
|
||||||
|
dataTypes: [{ literals: [cls], valType: "iri" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const obs = watchShape(shape, "protected") as ShapeObservable;
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
const initial = obs.getSnapshot();
|
||||||
|
(window as any).__sdk._wsProbes[handle] = { obs, initial, unsub };
|
||||||
|
// Keep the identity set for the probe's lifetime (watchShapeStop clears it): a
|
||||||
|
// faithful "empty scope for a real identity", not "no identity at all".
|
||||||
|
return { initial: { isPending: initial.isPending, isSuccess: initial.isSuccess } };
|
||||||
|
},
|
||||||
|
|
||||||
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
// ── caps / read-filter (in-memory cap model) ─────────────────────────────
|
||||||
// The read-filter over the injected useShape Set-like. Boundary note: the
|
// The read-filter over the injected useShape Set-like. Boundary note: the
|
||||||
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
||||||
|
|||||||
@@ -56,17 +56,62 @@ export function enabled(): boolean {
|
|||||||
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
|
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
|
||||||
* the discriminating signal for the isolation leak. NOT the physical wallet id
|
* the discriminating signal for the isolation leak. NOT the physical wallet id
|
||||||
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
|
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
|
||||||
|
* Exported so every other polyfill-layer log site (store-registry, inbox,
|
||||||
|
* outbox-log, …) shares the exact same identity resolution as the access log,
|
||||||
|
* instead of re-deriving it — see {@link accessLogPrefix}.
|
||||||
*/
|
*/
|
||||||
function activeIdentity(): string {
|
export function activeIdentity(): string {
|
||||||
return getCurrentUser() ?? "(none)";
|
return getCurrentUser() ?? "(none)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The common line prefix for every polyfill-layer low-level-data-path log:
|
||||||
|
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
|
||||||
|
* `[polyfill]` glued right after with no space between the two brackets. Used by
|
||||||
|
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
|
||||||
|
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
|
||||||
|
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
|
||||||
|
* log line is visually groupable by identity when scanning a live session.
|
||||||
|
*/
|
||||||
|
export function accessLogPrefix(): string {
|
||||||
|
return "[" + activeIdentity() + "][polyfill]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
|
||||||
|
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
|
||||||
|
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
|
||||||
|
* line per stage/event, never a dump. Callers pass the fully-composed suffix
|
||||||
|
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
|
||||||
|
*/
|
||||||
|
export function logStage(line: string): void {
|
||||||
|
if (!enabled()) return;
|
||||||
|
console.log(accessLogPrefix() + " " + line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
|
||||||
|
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
|
||||||
|
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
|
||||||
|
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
|
||||||
|
* returned unchanged (best-effort, this is only a diagnostic label).
|
||||||
|
*/
|
||||||
|
export function shortNuri(nuri: string): string {
|
||||||
|
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
|
||||||
|
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
|
||||||
|
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log one document access — but ONLY when {@link enabled}. Off → returns
|
* Log one document access — but ONLY when {@link enabled}. Off → returns
|
||||||
* immediately, prints nothing. Format:
|
* immediately, prints nothing. Format:
|
||||||
* `[<identity>] READ <nuri> (<label>)` — optionally with `<extra>` appended
|
* `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
|
||||||
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that
|
* discriminating scan signal), `[polyfill]` glued right after with no space
|
||||||
* should see nothing).
|
* between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
|
||||||
|
* rows`, a strong signal a doc rendered data under an identity that should see
|
||||||
|
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
|
||||||
|
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
|
||||||
|
* to keep the line scannable.
|
||||||
*/
|
*/
|
||||||
export function logAccess(
|
export function logAccess(
|
||||||
op: AccessOp,
|
op: AccessOp,
|
||||||
@@ -76,6 +121,6 @@ export function logAccess(
|
|||||||
): void {
|
): void {
|
||||||
if (!enabled()) return;
|
if (!enabled()) return;
|
||||||
console.log(
|
console.log(
|
||||||
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""),
|
accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
import * as inbox from "./inbox";
|
import * as inbox from "./inbox";
|
||||||
import { subscribeDoc } from "./subscribe";
|
import { subscribeDoc } from "./subscribe";
|
||||||
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
import { ensureAccount, reservedAccount } from "./store-registry";
|
import { ensureAccount, reservedAccount } from "./store-registry";
|
||||||
import { getCaps } from "./polyfill";
|
import { getCaps } from "./polyfill";
|
||||||
import type { Nuri, PrincipalId } from "./types";
|
import type { Nuri, PrincipalId } from "./types";
|
||||||
@@ -101,6 +102,17 @@ async function indexInboxNuri(): Promise<Nuri> {
|
|||||||
return record.docPublic;
|
return record.docPublic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NURI of the global discovery-index document (the inbox where submissions
|
||||||
|
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
|
||||||
|
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
|
||||||
|
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
|
||||||
|
* anchor. Removed against real NextGraph along with the special account.
|
||||||
|
*/
|
||||||
|
export async function indexDocNuri(): Promise<Nuri> {
|
||||||
|
return indexInboxNuri();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a reference to the global discovery index — the SDK act "make this
|
* Submit a reference to the global discovery index — the SDK act "make this
|
||||||
* discoverable". Deposits `ref` into the index document's inbox via
|
* discoverable". Deposits `ref` into the index document's inbox via
|
||||||
@@ -143,6 +155,19 @@ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise
|
|||||||
*/
|
*/
|
||||||
export async function readIndex(): Promise<IndexEntry[]> {
|
export async function readIndex(): Promise<IndexEntry[]> {
|
||||||
const target = await indexInboxNuri();
|
const target = await indexInboxNuri();
|
||||||
|
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
|
||||||
|
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
|
||||||
|
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
|
||||||
|
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
|
||||||
|
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
|
||||||
|
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
|
||||||
|
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
|
||||||
|
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
|
||||||
|
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
|
||||||
|
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
|
||||||
|
// repo open via its own subscription and must not spawn a second bootstrap open. See
|
||||||
|
// open-repo.ts.
|
||||||
|
await ensureRepoOpen(target);
|
||||||
const deposits = await inbox.read(target);
|
const deposits = await inbox.read(target);
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const entries: IndexEntry[] = [];
|
const entries: IndexEntry[] = [];
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ export async function sparqlQuery(
|
|||||||
// rows under an identity that should see nothing) can be appended. Skip the
|
// rows under an identity that should see nothing) can be appended. Skip the
|
||||||
// rowCount work entirely when the log is off.
|
// rowCount work entirely when the log is off.
|
||||||
if (accessLogEnabled()) {
|
if (accessLogEnabled()) {
|
||||||
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " rows");
|
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
|
||||||
|
// domain objects — one document's entity is spread across several triple rows.
|
||||||
|
// Spell that out so the log isn't mistaken for an object count (the app-level
|
||||||
|
// object/shape count is logged separately by useShapeQuery → dataStats).
|
||||||
|
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,10 @@
|
|||||||
|
|
||||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
import { sparqlUpdate, sparqlQuery } from "./docs";
|
||||||
import { subscribeDoc } from "./subscribe";
|
import { subscribeDoc } from "./subscribe";
|
||||||
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||||
import { escapeLiteral } from "./sparql";
|
import { escapeLiteral } from "./sparql";
|
||||||
|
import { accessLogPrefix } from "./access-log";
|
||||||
import type { Nuri, PrincipalId } from "./types";
|
import type { Nuri, PrincipalId } from "./types";
|
||||||
|
|
||||||
// --- deposit model --------------------------------------------------------
|
// --- deposit model --------------------------------------------------------
|
||||||
@@ -156,6 +158,13 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
|||||||
*/
|
*/
|
||||||
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||||
const sid = await sessionId();
|
const sid = await sessionId();
|
||||||
|
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
||||||
|
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
|
||||||
|
// already holds the repo open via its own `subscribeDoc`, so opening a second
|
||||||
|
// bootstrap subscription from inside a watch's re-read would be redundant and can
|
||||||
|
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
|
||||||
|
// read leaves both callers correct: the watch path stays event-driven, and the
|
||||||
|
// cold direct-read path opens the repo explicitly before calling `read`.
|
||||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
|
||||||
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
|
||||||
// default graph, exactly where `post` writes.
|
// default graph, exactly where `post` writes.
|
||||||
@@ -188,6 +197,31 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
|||||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
||||||
export const materialize = read;
|
export const materialize = read;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
|
||||||
|
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
|
||||||
|
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
|
||||||
|
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
|
||||||
|
*
|
||||||
|
* Why this over a plain {@link read}: on a FRESH session over the persistent
|
||||||
|
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
|
||||||
|
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
|
||||||
|
* returns 0 deposits — even for a deposit a remote session already synced to the
|
||||||
|
* broker. Gating on the sync barrier makes the read see the synced deposits. This
|
||||||
|
* is the SAME cold-read heal `discovery.readIndex` applies to the index inbox.
|
||||||
|
*
|
||||||
|
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
|
||||||
|
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
|
||||||
|
* re-read would be redundant and could race the watch's own initial-`State`
|
||||||
|
* delivery. Use this from a COLD reader (materialize-at-connection), like
|
||||||
|
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
|
||||||
|
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
|
||||||
|
*/
|
||||||
|
export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
|
||||||
|
await ensureRepoOpen(targetInbox);
|
||||||
|
return read(targetInbox);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
|
||||||
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
||||||
@@ -222,7 +256,7 @@ export function watch(
|
|||||||
onDeposits(deposits);
|
onDeposits(deposits);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[inbox] watch read failed:", error);
|
console.error(accessLogPrefix() + " watch read failed:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { useShape } from "./use-shape";
|
export { useShape } from "./use-shape";
|
||||||
|
export { watchShape } from "./watch-shape";
|
||||||
|
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
|
||||||
export { init, initNg } from "./lifecycle";
|
export { init, initNg } from "./lifecycle";
|
||||||
export * as inbox from "./inbox";
|
export * as inbox from "./inbox";
|
||||||
export * as discovery from "./discovery";
|
export * as discovery from "./discovery";
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
|
|
||||||
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
||||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||||
|
import { logStage, shortNuri } from "./access-log";
|
||||||
import type { Nuri } from "./types";
|
import type { Nuri } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,7 +92,17 @@ let boundSessionId: string | number | null = null;
|
|||||||
* pathological case (a doc that never pushes), so a read is never blocked forever —
|
* pathological case (a doc that never pushes), so a read is never blocked forever —
|
||||||
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
|
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
|
||||||
*/
|
*/
|
||||||
const OPEN_TIMEOUT_MS = 8000;
|
let OPEN_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out
|
||||||
|
* branch (a doc that never pushes a `State`) is otherwise only reachable after the
|
||||||
|
* 8s production wait, too slow for a unit test. Production never calls this — the
|
||||||
|
* default stands. `resetOpenedRepos` restores the default.
|
||||||
|
*/
|
||||||
|
export function setOpenTimeoutForTests(ms: number): void {
|
||||||
|
OPEN_TIMEOUT_MS = ms;
|
||||||
|
}
|
||||||
|
|
||||||
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the
|
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the
|
||||||
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
|
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
|
||||||
@@ -108,17 +119,7 @@ export function resetOpenedRepos(): void {
|
|||||||
held.clear();
|
held.clear();
|
||||||
syncState.clear();
|
syncState.clear();
|
||||||
boundSessionId = null;
|
boundSessionId = null;
|
||||||
}
|
OPEN_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
/**
|
|
||||||
* @internal TEST-ONLY — force a nuri into the opened registry with a given sync
|
|
||||||
* state, without going through `doc_subscribe`. Used by `anti-fork.test.ts` to
|
|
||||||
* simulate a timed-out barrier without waiting the full `OPEN_TIMEOUT_MS` (8s).
|
|
||||||
* Do NOT call from production code.
|
|
||||||
*/
|
|
||||||
export function _forceOpenedSyncState(nuri: Nuri, state: SyncState): void {
|
|
||||||
opened.add(nuri);
|
|
||||||
syncState.set(nuri, state);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,6 +183,11 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
|||||||
|
|
||||||
// Subscribed, first `State` not yet seen.
|
// Subscribed, first `State` not yet seen.
|
||||||
syncState.set(nuri, "syncing");
|
syncState.set(nuri, "syncing");
|
||||||
|
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
|
||||||
|
// took, surfaced on the BARRIER trace line below — the most important line in
|
||||||
|
// the whole low-level data-path trace: it distinguishes a genuine absence
|
||||||
|
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
|
||||||
|
const barrierStartedAt = Date.now();
|
||||||
|
|
||||||
const p = (async () => {
|
const p = (async () => {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -191,6 +197,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
syncState.set(nuri, "synced");
|
syncState.set(nuri, "synced");
|
||||||
|
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
|
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
|
||||||
@@ -200,6 +207,7 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
|||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
syncState.set(nuri, "timed-out");
|
syncState.set(nuri, "timed-out");
|
||||||
|
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
// The bootstrap subscription is kept ALIVE for the session — holding it open
|
// The bootstrap subscription is kept ALIVE for the session — holding it open
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
|
||||||
|
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
|
||||||
|
*
|
||||||
|
* ── What this surfaces ──────────────────────────────────────────────────────
|
||||||
|
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
|
||||||
|
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
|
||||||
|
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
|
||||||
|
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
|
||||||
|
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
|
||||||
|
* surfacing unconditionally: it means writes from a previous (disconnected)
|
||||||
|
* session are still queued and haven't reached the broker yet.
|
||||||
|
*
|
||||||
|
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
|
||||||
|
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
|
||||||
|
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
|
||||||
|
* key families written by `session_write`/read by `session_read`:
|
||||||
|
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
|
||||||
|
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
|
||||||
|
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
|
||||||
|
* event per zero-padded index, contiguous from 0 until the first miss (the
|
||||||
|
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
|
||||||
|
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
|
||||||
|
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
|
||||||
|
* requiring it to be passed in — this also means the probe works unchanged
|
||||||
|
* however many peers/wallets the browser session has touched.
|
||||||
|
*
|
||||||
|
* ── Read-only, defensive, best-effort ────────────────────────────────────────
|
||||||
|
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
|
||||||
|
* which drains on read) — it only counts. The queued event bytes are opaque
|
||||||
|
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
|
||||||
|
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
|
||||||
|
* format in this polyfill, which is explicitly out of scope (SDK internals live
|
||||||
|
* in the `@ng-eventually/client`-independent core repo, per this repo's
|
||||||
|
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
|
||||||
|
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
|
||||||
|
* see the exact error string handled in the core repo's `main.ts`
|
||||||
|
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
|
||||||
|
* → skip silently, never throw into the caller.
|
||||||
|
*
|
||||||
|
* Polyfill-era; removed at the real multi-store migration alongside the rest of
|
||||||
|
* this low-level trace instrumentation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { accessLogPrefix, logStage } from "./access-log";
|
||||||
|
|
||||||
|
/** Matches an outbox "start" marker key, capturing the peer id. */
|
||||||
|
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
|
||||||
|
|
||||||
|
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
|
||||||
|
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
|
||||||
|
* Real outboxes are queued-while-offline writes — nowhere near this size. */
|
||||||
|
const MAX_SCAN_PER_PEER = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the outbox NOW and log its state — count only, never targets (see
|
||||||
|
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
|
||||||
|
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
|
||||||
|
* access-log flag like the rest of the low-level trace. Read-only: never
|
||||||
|
* mutates `sessionStorage`. Never throws.
|
||||||
|
*/
|
||||||
|
export function inspectOutbox(): void {
|
||||||
|
try {
|
||||||
|
const storage = (globalThis as any)?.sessionStorage;
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
const peers = new Set<string>();
|
||||||
|
const length: number = storage.length ?? 0;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const key = storage.key?.(i);
|
||||||
|
if (!key) continue;
|
||||||
|
const m = OUTBOX_START_KEY.exec(key);
|
||||||
|
const peerId = m?.[1];
|
||||||
|
if (peerId) peers.add(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
for (const peer of peers) {
|
||||||
|
let idx = 0;
|
||||||
|
while (idx < MAX_SCAN_PER_PEER) {
|
||||||
|
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
|
||||||
|
if (storage.getItem(idxKey) === null) break;
|
||||||
|
total++;
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total > 0) {
|
||||||
|
// Anomaly: ALWAYS visible, regardless of the access-log flag.
|
||||||
|
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
|
||||||
|
} else {
|
||||||
|
logStage("OUTBOX empty");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable / access denied — skip silently. Diagnostic
|
||||||
|
// only, never a hard dependency of the read/write path.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
|||||||
import type { RegistrySession } from "./store-registry";
|
import type { RegistrySession } from "./store-registry";
|
||||||
import { CapRegistry } from "./caps";
|
import { CapRegistry } from "./caps";
|
||||||
import { setAccessLog } from "./access-log";
|
import { setAccessLog } from "./access-log";
|
||||||
|
import { inspectOutbox } from "./outbox-log";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||||
@@ -25,19 +26,18 @@ export interface StoreRegistryDeps {
|
|||||||
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
|
||||||
normalizeId?: (id: string) => string;
|
normalizeId?: (id: string) => string;
|
||||||
/**
|
/**
|
||||||
* ANTI-FORK budget for account resolution (polyfill-era). Before provisioning
|
* POINTER micro-guard budget. The account records now live in a subscribable
|
||||||
* a "missing" account, the registry re-reads its shim record a bounded number
|
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
|
||||||
* of times with backoff, so a record merely lagging by broker sync (fresh
|
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
|
||||||
* session over a persistent wallet) is FOUND and REUSED instead of triggering
|
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
|
||||||
* a second set of scope docs (the account fork). See the note in
|
* ONLY residual sync-lag window is the store-root pointer read itself — one
|
||||||
* store-registry.ts. Only if every attempt still reads 0 do we provision.
|
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
|
||||||
*
|
* fresh cold read misses it; it can never provision or fork an account (worst case:
|
||||||
* Default (production): a real budget (~8 attempts / ≲8.5s). Set `attempts: 1`
|
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
|
||||||
* to disable the retry entirely — appropriate for a SYNCHRONOUS in-memory
|
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
|
||||||
* store (the unit fake `ng`) where a 0-row read is authoritative and the
|
* read, keeping the synchronous unit fakes fast and unchanged.
|
||||||
* backoff would only add dead time; there is no sync lag to wait out there.
|
|
||||||
*/
|
*/
|
||||||
provisionRetry?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventuallyConfig {
|
export interface EventuallyConfig {
|
||||||
@@ -64,7 +64,12 @@ export interface EventuallyConfig {
|
|||||||
|
|
||||||
let cfg: EventuallyConfig | null = null;
|
let cfg: EventuallyConfig | null = null;
|
||||||
let currentUser: PrincipalId | null = null;
|
let currentUser: PrincipalId | null = null;
|
||||||
let registryDeps: Required<StoreRegistryDeps> | null = null;
|
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
|
||||||
|
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
|
||||||
|
type ResolvedRegistryDeps = Required<
|
||||||
|
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||||
|
>;
|
||||||
|
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
||||||
* while it has no read policy the read filter passes through (no regression). */
|
* while it has no read policy the read filter passes through (no regression). */
|
||||||
let caps = new CapRegistry();
|
let caps = new CapRegistry();
|
||||||
@@ -95,21 +100,35 @@ export function resetConfig(): void {
|
|||||||
* disappears at migration.
|
* disappears at migration.
|
||||||
*/
|
*/
|
||||||
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
|
||||||
|
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
|
||||||
|
// on the FIRST successful `getSession()` resolution — the most reliable
|
||||||
|
// "a session is established" signal available: every low-level reader/writer
|
||||||
|
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
|
||||||
|
// session through this SAME injected `getSession`, so wrapping it HERE catches
|
||||||
|
// the first success from whichever caller happens to run first, instead of
|
||||||
|
// tying the probe to one particular call site. Only on SUCCESS (an error
|
||||||
|
// propagates untouched, exactly as before) and only ONCE per
|
||||||
|
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
|
||||||
|
let outboxInspected = false;
|
||||||
|
const getSession = async (): Promise<RegistrySession> => {
|
||||||
|
const session = await deps.getSession();
|
||||||
|
if (!outboxInspected) {
|
||||||
|
outboxInspected = true;
|
||||||
|
inspectOutbox();
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
};
|
||||||
registryDeps = {
|
registryDeps = {
|
||||||
getSession: deps.getSession,
|
getSession,
|
||||||
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
|
||||||
// Production default: a real anti-fork budget. Consumers over a synchronous
|
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
|
||||||
// store pass `{ attempts: 1 }` to opt out (see StoreRegistryDeps).
|
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
|
||||||
provisionRetry: {
|
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
|
||||||
attempts: deps.provisionRetry?.attempts ?? 8,
|
|
||||||
baseMs: deps.provisionRetry?.baseMs ?? 300,
|
|
||||||
maxStepMs: deps.provisionRetry?.maxStepMs ?? 1500,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
/** @internal — used by the storeRegistry to reach its injected dependencies. */
|
||||||
export function getStoreRegistryDeps(): Required<StoreRegistryDeps> {
|
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
|
||||||
if (!registryDeps) {
|
if (!registryDeps) {
|
||||||
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,37 @@
|
|||||||
* physical NextGraph store. Isolation is enforced by the app layer + the
|
* physical NextGraph store. Isolation is enforced by the app layer + the
|
||||||
* emulated cap registry, not by crypto.
|
* emulated cap registry, not by crypto.
|
||||||
*
|
*
|
||||||
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**,
|
* The mapping (account → its 3 document NURIs) is the **sharedWalletShim**. It
|
||||||
* persisted as RDF in the shared wallet's private store (the anchor, always
|
* is persisted as RDF, but NOT in the store-root graph anymore — see the
|
||||||
* known from the session). That makes login cross-device: another device
|
* indirection below. That makes login cross-device: another device opening the
|
||||||
* opening the same wallet reads the same shim and finds the same accounts.
|
* same wallet reads the same shim and finds the same accounts.
|
||||||
|
*
|
||||||
|
* ── The indirection: pointer (store-root) → doc-shim (subscribable) ─────────
|
||||||
|
* On the real NextGraph platform "findable-without-lookup" and "subscribable"
|
||||||
|
* are DISJOINT (verified at the source, see docs/nextgraph-current-state.md
|
||||||
|
* § *Findable vs subscribable*):
|
||||||
|
* - the ONLY NURI a fresh session can name WITHOUT a lookup is the store-root
|
||||||
|
* `did:ng:${privateStoreId}` — but a store-root has NO first-`State` sync
|
||||||
|
* BARRIER, so a cold "0 rows" on it is AMBIGUOUS (could be sync-lag, could
|
||||||
|
* be truly empty);
|
||||||
|
* - the ONLY thing that DOES have a first-`State` barrier is a `did:ng:o:<RepoID>`
|
||||||
|
* doc from `doc_create` — but its RepoID is RANDOM, so a fresh session
|
||||||
|
* cannot GUESS it; it must be looked up.
|
||||||
|
* So a purely-barrier shim resolution is impossible: you cannot have a doc that
|
||||||
|
* is both guessable and authoritative on a cold read. The indirection bridges
|
||||||
|
* this: a well-known, write-ONCE **pointer** triple in the store-root names a
|
||||||
|
* **doc-shim** (`did:ng:o:...`) that holds all account records and IS
|
||||||
|
* subscribable. Resolution reads the pointer (a single oldest write-once triple,
|
||||||
|
* near-always synced), then opens the doc-shim through its `ensureRepoOpen`
|
||||||
|
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
|
||||||
|
*
|
||||||
|
* A pointer FORK (two devices writing the pointer before either synced) is
|
||||||
|
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
|
||||||
|
* device converges on the SAME doc-shim. This is why the OLD account-level retry
|
||||||
|
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
|
||||||
|
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
|
||||||
|
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
|
||||||
|
* triple) — see resolvePointer.
|
||||||
*
|
*
|
||||||
* ── Generic by construction ──────────────────────────────────────────────
|
* ── Generic by construction ──────────────────────────────────────────────
|
||||||
* This module knows only the three native scopes; it knows no application
|
* This module knows only the three native scopes; it knows no application
|
||||||
@@ -35,32 +62,8 @@
|
|||||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||||
import { getStoreRegistryDeps } from "./polyfill";
|
import { getStoreRegistryDeps } from "./polyfill";
|
||||||
import { ensureRepoOpen } from "./open-repo";
|
import { ensureRepoOpen } from "./open-repo";
|
||||||
|
|
||||||
// --- provisioning anti-fork guard: barrier-first (polyfill-era) ------------
|
|
||||||
//
|
|
||||||
// The shim (account→docs trust root) lives in the shared wallet's PRIVATE store.
|
|
||||||
// On a fresh session over a persistent wallet, that repo may not have synced yet
|
|
||||||
// from the broker: a targeted account resolve would then read 0 rows even though
|
|
||||||
// the account WAS persisted in an earlier session. If ensureAccount took that 0
|
|
||||||
// at face value it would RE-PROVISION a second set of scope documents — an account
|
|
||||||
// FORK: one session writes/reads one set, another (or the same after a cache drop)
|
|
||||||
// the other, EMPTY set → the user "loses" their data on reconnect.
|
|
||||||
//
|
|
||||||
// Guard (barrier-first): BEFORE reading the shim, open/subscribe the private-store
|
|
||||||
// repo and AWAIT its first `State` push — the deterministic sync barrier (CONTRACT 3
|
|
||||||
// in e2e/). After the barrier, presence is GUARANTEED and absence DEFINITIVE. Then
|
|
||||||
// read the shim ONCE: 0 rows = account genuinely absent → provision exactly once;
|
|
||||||
// rows present = account found → reuse it (NO-FORK). No retry loop, no poll.
|
|
||||||
//
|
|
||||||
// Timed-out barrier (conservative): if `ensureRepoOpen` exits via the 8-second
|
|
||||||
// fallback timeout (getSyncState → "timed-out") the sync is UNCONFIRMED — we do NOT
|
|
||||||
// provision blindly (that would re-fork). Instead we surface a clear error so the
|
|
||||||
// caller can retry the full flow rather than silently creating a duplicate account.
|
|
||||||
// The timeout is rare (pathological broker) and a hard error there is far safer than
|
|
||||||
// a silent fork. Disappears at the real multi-store migration (per-user store NURIs
|
|
||||||
// make this moot — barrier becomes the native store-open which is always confirmed).
|
|
||||||
import { getSyncState } from "./open-repo";
|
|
||||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||||
|
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||||
import type { Nuri, Scope } from "./types";
|
import type { Nuri, Scope } from "./types";
|
||||||
|
|
||||||
// --- sharedWalletShim model ----------------------------------------------
|
// --- sharedWalletShim model ----------------------------------------------
|
||||||
@@ -87,6 +90,18 @@ const P = {
|
|||||||
// documents (one per entity) that live "in" that scope.
|
// documents (one per entity) that live "in" that scope.
|
||||||
const INDEX_SUBJECT = `${SHIM}:index`;
|
const INDEX_SUBJECT = `${SHIM}:index`;
|
||||||
|
|
||||||
|
// --- pointer (store-root → doc-shim indirection) --------------------------
|
||||||
|
//
|
||||||
|
// The pointer is a SINGLE well-known triple written ONCE into the store-root
|
||||||
|
// graph on the very first login, then IMMUTABLE. Its object is the NURI of the
|
||||||
|
// doc-shim (a `did:ng:o:...` repo) where all AccountRecords actually live. The
|
||||||
|
// store-root is NOT subscribable (no first-`State` barrier), but the pointer is
|
||||||
|
// the OLDEST triple in that graph and is write-once, so it is near-always synced
|
||||||
|
// on a cold read — and even a transient miss is bounded by a small guard
|
||||||
|
// (resolvePointer), NOT by an account-level retry.
|
||||||
|
const POINTER_SUBJECT = `${SHIM}:root`;
|
||||||
|
const POINTER_PRED = `${SHIM}:shimDoc`;
|
||||||
|
|
||||||
function accountSubject(id: string): string {
|
function accountSubject(id: string): string {
|
||||||
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
// The id is UNTRUSTED and lands in an IRI position. Percent-encode it
|
||||||
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of
|
||||||
@@ -135,7 +150,7 @@ function accountKey(id: string): string {
|
|||||||
/** Minimal session shape the registry needs — provided by the consumer. */
|
/** Minimal session shape the registry needs — provided by the consumer. */
|
||||||
export interface RegistrySession {
|
export interface RegistrySession {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** The shared wallet's private store id — the shim anchor. */
|
/** The shared wallet's private store id — the pointer anchor. */
|
||||||
privateStoreId: string;
|
privateStoreId: string;
|
||||||
/** The shared wallet's protected store id (native store). Optional: only the
|
/** The shared wallet's protected store id (native store). Optional: only the
|
||||||
* scope resolvers need it; the shim only needs the private anchor. */
|
* scope resolvers need it; the shim only needs the private anchor. */
|
||||||
@@ -152,8 +167,11 @@ async function session(): Promise<RegistrySession> {
|
|||||||
return getStoreRegistryDeps().getSession();
|
return getStoreRegistryDeps().getSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shim lives in the shared wallet's private store (always-known anchor). */
|
/** The pointer lives in the shared wallet's private STORE-ROOT graph (the only
|
||||||
async function anchorNuri(): Promise<Nuri> {
|
* always-known-without-lookup anchor). NOT subscribable — hence the pointer is
|
||||||
|
* a write-once triple, and the actual account records live in the doc-shim it
|
||||||
|
* names (see rootNuri vs the doc-shim). */
|
||||||
|
async function rootNuri(): Promise<Nuri> {
|
||||||
const s = await session();
|
const s = await session();
|
||||||
return `did:ng:${s.privateStoreId}`;
|
return `did:ng:${s.privateStoreId}`;
|
||||||
}
|
}
|
||||||
@@ -169,10 +187,20 @@ let cache: Map<string, AccountRecord> | null = null;
|
|||||||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||||||
const accountCache = new Map<string, AccountRecord>();
|
const accountCache = new Map<string, AccountRecord>();
|
||||||
|
|
||||||
|
// The resolved doc-shim NURI for the current session (cached: the pointer read +
|
||||||
|
// barrier open happen once, then every account read reuses this doc). Cleared on
|
||||||
|
// resetRegistryCache / wallet switch.
|
||||||
|
let shimDocNuri: Nuri | null = null;
|
||||||
|
// De-dupe concurrent pointer-resolutions so a fresh page firing many parallel
|
||||||
|
// ensureAccount/resolveAccount calls opens the doc-shim exactly once.
|
||||||
|
let shimDocInFlight: Promise<Nuri> | null = null;
|
||||||
|
|
||||||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||||||
export function resetRegistryCache(): void {
|
export function resetRegistryCache(): void {
|
||||||
cache = null;
|
cache = null;
|
||||||
accountCache.clear();
|
accountCache.clear();
|
||||||
|
shimDocNuri = null;
|
||||||
|
shimDocInFlight = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SPARQL result helpers ------------------------------------------------
|
// --- SPARQL result helpers ------------------------------------------------
|
||||||
@@ -192,147 +220,157 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
|
|||||||
return row[key]?.value ?? "";
|
return row[key]?.value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- shim load / account bootstrap ----------------------------------------
|
|
||||||
|
|
||||||
/** Load all accounts from the shim into the cache. */
|
|
||||||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
|
||||||
if (cache) return cache;
|
|
||||||
const s = await session();
|
|
||||||
const anchor = await anchorNuri();
|
|
||||||
const query = `
|
|
||||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
?acc a <${P.type}> ;
|
|
||||||
<${P.id}> ?id ;
|
|
||||||
<${P.docPublic}> ?docPublic ;
|
|
||||||
<${P.docProtected}> ?docProtected ;
|
|
||||||
<${P.docPrivate}> ?docPrivate .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
const map = new Map<string, AccountRecord>();
|
|
||||||
try {
|
|
||||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "loadShim");
|
|
||||||
for (const row of readBindings(result)) {
|
|
||||||
const id = bindingValue(row, "id");
|
|
||||||
if (!id) continue;
|
|
||||||
const key = accountKey(id);
|
|
||||||
const record: AccountRecord = {
|
|
||||||
id,
|
|
||||||
docPublic: bindingValue(row, "docPublic"),
|
|
||||||
docProtected: bindingValue(row, "docProtected"),
|
|
||||||
docPrivate: bindingValue(row, "docPrivate"),
|
|
||||||
};
|
|
||||||
map.set(key, record);
|
|
||||||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
|
||||||
accountCache.set(key, record);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] loadShim failed:", error);
|
|
||||||
}
|
|
||||||
cache = map;
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
* DETERMINISTIC resolution of an account's scope docs from a set of SPARQL
|
||||||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
* bindings (all bindings for ONE account subject).
|
||||||
* the account record at its known subject (`accountSubject(id)`) directly,
|
|
||||||
* instead of scanning EVERY account like {@link loadShim}. Returns the account's
|
|
||||||
* record or `null` if it does not exist yet.
|
|
||||||
*
|
*
|
||||||
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
* ── Why this is load-bearing (residual fork residue) ───────────────────────
|
||||||
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
* A corrupted shim can carry the SAME account subject with MULTIPLE values for a
|
||||||
|
* scope predicate (e.g. 5 `shim:docPublic`) — the residue of past account FORKS
|
||||||
|
* (each stray provision appended another doc NURI). A query then returns several
|
||||||
|
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
|
||||||
|
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
|
||||||
|
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
|
||||||
|
* DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 →
|
||||||
|
* the home reads empty. When both happen to pick the same doc, it "works".
|
||||||
|
*
|
||||||
|
* The fix: for each scope field, collect EVERY distinct value across the bindings
|
||||||
|
* and choose the SAME one every time — the lexicographically-smallest NURI. NURIs
|
||||||
|
* are content-addressed and stable, so lexicographic order is a total, stable,
|
||||||
|
* session-independent order: writer and reader converge on the SAME canonical doc
|
||||||
|
* even on a wallet already corrupted by duplicates. (No creation timestamp is
|
||||||
|
* recorded in the shim, so lexicographic-min is the available deterministic key.)
|
||||||
|
*
|
||||||
|
* With the barrier-authoritative doc-shim read, account FORKS no longer occur (a
|
||||||
|
* fresh page reads the doc-shim through its first-`State` barrier, so a cold 0 is
|
||||||
|
* definitive and never triggers a fork-provision). `canonicalDoc` is RETAINED to
|
||||||
|
* stay robust against the residue of PAST forks already persisted in a wallet, and
|
||||||
|
* to reconcile a benign pointer fork the same content-addressed way.
|
||||||
*/
|
*/
|
||||||
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
|
||||||
const key = accountKey(id);
|
let chosen = "";
|
||||||
const cached = accountCache.get(key);
|
const distinct = new Set<string>();
|
||||||
if (cached) return cached;
|
for (const row of rows) {
|
||||||
|
const v = bindingValue(row, key);
|
||||||
const s = await session();
|
if (!v) continue;
|
||||||
const anchor = await anchorNuri();
|
distinct.add(v);
|
||||||
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
|
if (chosen === "" || v < chosen) chosen = v;
|
||||||
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
|
|
||||||
const subj = accountSubject(id);
|
|
||||||
const query = `
|
|
||||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
<${subj}> a <${P.type}> ;
|
|
||||||
<${P.id}> ?id ;
|
|
||||||
<${P.docPublic}> ?docPublic ;
|
|
||||||
<${P.docProtected}> ?docProtected ;
|
|
||||||
<${P.docPrivate}> ?docPrivate .
|
|
||||||
}
|
}
|
||||||
}`;
|
// Stage trace: which doc got picked, and out of how many DISTINCT candidate
|
||||||
try {
|
// values — >1 flags residual fork residue (see the module doc above) even
|
||||||
const result = await sparqlQuery(s.sessionId, query, undefined, anchor, "resolveAccount");
|
// when resolution still converges correctly on the canonical (smallest) one.
|
||||||
const rows = readBindings(result);
|
logStage(
|
||||||
if (rows.length === 0) return null;
|
"canonicalDoc(" + key + ") → " + (chosen ? shortNuri(chosen) : "none") +
|
||||||
const row = rows[0]!;
|
" (" + distinct.size + (distinct.size === 1 ? " candidate)" : " candidates)"),
|
||||||
const record: AccountRecord = {
|
|
||||||
id: bindingValue(row, "id") || id,
|
|
||||||
docPublic: bindingValue(row, "docPublic"),
|
|
||||||
docProtected: bindingValue(row, "docProtected"),
|
|
||||||
docPrivate: bindingValue(row, "docPrivate"),
|
|
||||||
};
|
|
||||||
accountCache.set(key, record);
|
|
||||||
return record;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] resolveAccount failed:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve ONE account after waiting for the private-store sync barrier, so a
|
|
||||||
* 0-row result is DEFINITIVE (account genuinely absent) rather than ambiguous
|
|
||||||
* (broker lag). This is the ANTI-FORK guard for the polyfill-era shim.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Cache hit → return immediately (session-idempotent, no I/O).
|
|
||||||
* 2. Open/subscribe the private-store repo (`did:ng:${privateStoreId}`) and
|
|
||||||
* await the first `State` push — the deterministic sync barrier (CONTRACT 3).
|
|
||||||
* After the barrier, presence is guaranteed and absence definitive.
|
|
||||||
* 3. If the barrier TIMED-OUT (getSyncState → "timed-out"), the sync is
|
|
||||||
* unconfirmed — provisioning here would risk a fork. Throw a clear error
|
|
||||||
* instead so the caller can retry the full login flow. This is the
|
|
||||||
* conservative-safe choice: a hard error is recoverable; a silent fork is not.
|
|
||||||
* 4. Read the shim ONCE. 0 rows = genuinely absent → caller provisions exactly
|
|
||||||
* once. Rows present → reuse (NO-FORK preserved).
|
|
||||||
*
|
|
||||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
|
||||||
* (getSyncState → "unknown") and the single read is immediate — synchronous
|
|
||||||
* behaviour preserved, no lag to wait out.
|
|
||||||
*/
|
|
||||||
async function resolveAccountReliably(id: string): Promise<AccountRecord | null> {
|
|
||||||
// Cache hit → session-idempotent, no query, no fork risk.
|
|
||||||
const cached = accountCache.get(accountKey(id));
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
// The shim lives in the private store. Open/subscribe it and await the first
|
|
||||||
// `State` push (the sync barrier). NURI: `did:ng:${session.privateStoreId}`.
|
|
||||||
const nuri = await anchorNuri();
|
|
||||||
await ensureRepoOpen(nuri);
|
|
||||||
|
|
||||||
// Conservative timed-out guard: if the barrier did not confirm sync,
|
|
||||||
// provisioning blind risks creating a fork. Raise a clear error — the caller
|
|
||||||
// (or the app's retry-login flow) should re-attempt when the broker is reachable.
|
|
||||||
// "unknown" means the fake-ng no-op path: no barrier semantics → safe to read.
|
|
||||||
const syncResult = getSyncState(nuri);
|
|
||||||
if (syncResult === "timed-out") {
|
|
||||||
throw new Error(
|
|
||||||
"[storeRegistry] sync barrier timed out for the private store — " +
|
|
||||||
"shim state unconfirmed, refusing to provision to avoid account fork. " +
|
|
||||||
"Retry when the broker is reachable.",
|
|
||||||
);
|
);
|
||||||
|
return chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Barrier reached (or fake-ng "unknown" path): single read, result is definitive.
|
/** Build an AccountRecord by picking the canonical (lexicographically-smallest)
|
||||||
return resolveAccount(id);
|
* doc NURI per scope across all bindings for one account. See {@link canonicalDoc}. */
|
||||||
|
function recordFromRows(
|
||||||
|
rows: Array<Record<string, { value: string }>>,
|
||||||
|
fallbackId: string,
|
||||||
|
): AccountRecord {
|
||||||
|
let id = "";
|
||||||
|
for (const row of rows) {
|
||||||
|
const v = bindingValue(row, "id");
|
||||||
|
if (v) { id = v; break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: id || fallbackId,
|
||||||
|
docPublic: canonicalDoc(rows, "docPublic"),
|
||||||
|
docProtected: canonicalDoc(rows, "docProtected"),
|
||||||
|
docPrivate: canonicalDoc(rows, "docPrivate"),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All known accounts (from the shim). */
|
// --- pointer resolution + doc-shim bootstrap ------------------------------
|
||||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
|
||||||
return [...(await loadShim()).values()];
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the pointer(s) from the store-root graph → the canonical doc-shim NURI, or
|
||||||
|
* `""` if no pointer exists yet.
|
||||||
|
*
|
||||||
|
* The store-root is NOT subscribable, so this read has no first-`State` barrier:
|
||||||
|
* a cold 0 is ambiguous. But the pointer is ONE write-once triple (the OLDEST in
|
||||||
|
* that graph), so it is near-always synced. The ONLY residual guard is a small
|
||||||
|
* bounded re-read here (NOT an account retry): a handful of quick re-reads of that
|
||||||
|
* single triple. It is bounded, benign, and — crucially — it can never re-provision
|
||||||
|
* an account or fork data; the worst it can do is take a couple extra reads to see a
|
||||||
|
* pointer that is still landing. The account records themselves are read
|
||||||
|
* authoritatively through the doc-shim barrier, never through this guard.
|
||||||
|
*
|
||||||
|
* If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to
|
||||||
|
* their own freshly-created doc-shim before either synced), reconcile to the
|
||||||
|
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
|
||||||
|
* stable, so every device converges on the SAME doc-shim.
|
||||||
|
*/
|
||||||
|
async function resolvePointer(): Promise<Nuri> {
|
||||||
|
const s = await session();
|
||||||
|
const root = await rootNuri();
|
||||||
|
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
|
||||||
|
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
|
||||||
|
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
|
||||||
|
// barrier, so this open cannot make the read authoritative — the guard below does.
|
||||||
|
await ensureRepoOpen(root);
|
||||||
|
const query = `
|
||||||
|
SELECT ?shimDoc WHERE {
|
||||||
|
GRAPH <${assertNuri(root)}> {
|
||||||
|
<${POINTER_SUBJECT}> <${POINTER_PRED}> ?shimDoc .
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// Micro-guard (POINTER only): a small bounded re-read to bridge the store-root
|
||||||
|
// sync-lag window on the ONE write-once pointer triple. Bounded, and it can only
|
||||||
|
// ever DELAY seeing an existing pointer — never provision, never fork. Uses the
|
||||||
|
// injected pointerGuard budget (defaults to a single read when unset, so unit
|
||||||
|
// fakes stay synchronous). NB this is NOT the deleted account-level provisionRetry.
|
||||||
|
const budget = getStoreRegistryDeps().pointerGuard;
|
||||||
|
const attempts = Math.max(1, budget.attempts ?? 1);
|
||||||
|
const baseMs = budget.baseMs ?? 150;
|
||||||
|
const maxStepMs = budget.maxStepMs ?? 2000;
|
||||||
|
|
||||||
|
let step = baseMs;
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
|
||||||
|
const doc = canonicalDoc(readBindings(result), "shimDoc");
|
||||||
|
if (doc) {
|
||||||
|
logStage("resolvePointer → 1 target: " + shortNuri(doc));
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " resolvePointer failed:", error);
|
||||||
|
}
|
||||||
|
if (i < attempts - 1) {
|
||||||
|
await sleep(step);
|
||||||
|
step = Math.min(step * 2, maxStepMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logStage("resolvePointer → 0 targets");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the pointer (store-root → doc-shim), once, at first login. Idempotent in
|
||||||
|
* practice (only called when no pointer was found); a concurrent double-write is
|
||||||
|
* reconciled by canonicalDoc on read. */
|
||||||
|
async function writePointer(doc: Nuri): Promise<void> {
|
||||||
|
const s = await session();
|
||||||
|
const root = await rootNuri();
|
||||||
|
await ensureRepoOpen(root);
|
||||||
|
const update = `
|
||||||
|
INSERT DATA {
|
||||||
|
GRAPH <${assertNuri(root)}> {
|
||||||
|
<${POINTER_SUBJECT}> <${POINTER_PRED}> <${assertNuri(doc)}> .
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
try {
|
||||||
|
await sparqlUpdate(s.sessionId, update, root, "writePointer");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " writePointer failed:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
/** Create one graph document in the shared wallet's private store (→ a NURI). */
|
||||||
@@ -343,59 +381,258 @@ async function createDoc(): Promise<Nuri> {
|
|||||||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (or on first login, create) the doc-shim NURI for this session — the
|
||||||
|
* `did:ng:o:...` repo that holds every AccountRecord and IS subscribable.
|
||||||
|
*
|
||||||
|
* Steps (cached; runs at most once per session, concurrent callers share one):
|
||||||
|
* 1. Read the pointer from the store-root (resolvePointer). If present → that is
|
||||||
|
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
|
||||||
|
* reads are AUTHORITATIVE.
|
||||||
|
* 2. No pointer → FIRST login:
|
||||||
|
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
|
||||||
|
* session (`self.repos`) — so it is already open/synced in-session;
|
||||||
|
* b. publish the pointer (store-root → the new doc-shim), once;
|
||||||
|
* c. open it (barrier — trivially satisfied for a just-created in-session repo).
|
||||||
|
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
|
||||||
|
* on first-login creation it is a no-op, so the pointer is published first.
|
||||||
|
*/
|
||||||
|
async function resolveShimDoc(): Promise<Nuri> {
|
||||||
|
if (shimDocNuri) return shimDocNuri;
|
||||||
|
if (shimDocInFlight) return shimDocInFlight;
|
||||||
|
|
||||||
|
const p = (async (): Promise<Nuri> => {
|
||||||
|
const existing = await resolvePointer();
|
||||||
|
if (existing) {
|
||||||
|
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
|
||||||
|
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
|
||||||
|
await ensureRepoOpen(existing);
|
||||||
|
shimDocNuri = existing;
|
||||||
|
logStage("resolveShimDoc → " + shortNuri(existing));
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
|
||||||
|
// the pointer, then open (no-op barrier for a just-created repo).
|
||||||
|
const doc = await createDoc();
|
||||||
|
await writePointer(doc);
|
||||||
|
await ensureRepoOpen(doc);
|
||||||
|
shimDocNuri = doc;
|
||||||
|
logStage("resolveShimDoc → " + shortNuri(doc));
|
||||||
|
return doc;
|
||||||
|
})();
|
||||||
|
|
||||||
|
shimDocInFlight = p;
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
shimDocInFlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shim load / account bootstrap ----------------------------------------
|
||||||
|
|
||||||
|
/** Load all accounts from the shim (the doc-shim) into the cache. */
|
||||||
|
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||||
|
if (cache) return cache;
|
||||||
|
const s = await session();
|
||||||
|
const doc = await resolveShimDoc();
|
||||||
|
const query = `
|
||||||
|
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||||
|
?acc a <${P.type}> ;
|
||||||
|
<${P.id}> ?id ;
|
||||||
|
<${P.docPublic}> ?docPublic ;
|
||||||
|
<${P.docProtected}> ?docProtected ;
|
||||||
|
<${P.docPrivate}> ?docPrivate .
|
||||||
|
}`;
|
||||||
|
const map = new Map<string, AccountRecord>();
|
||||||
|
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
|
||||||
|
// authoritative.
|
||||||
|
await ensureRepoOpen(doc);
|
||||||
|
try {
|
||||||
|
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
|
||||||
|
// Group ALL bindings by account key first, then pick the CANONICAL doc per
|
||||||
|
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
|
||||||
|
// duplicate scope-doc values (fork residue) → several bindings; grouping +
|
||||||
|
// canonical selection makes loadShim resolve the SAME doc the targeted
|
||||||
|
// resolveAccount does, so full-scan and hot-path readers never disagree.
|
||||||
|
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
|
||||||
|
for (const row of readBindings(result)) {
|
||||||
|
const id = bindingValue(row, "id");
|
||||||
|
if (!id) continue;
|
||||||
|
const key = accountKey(id);
|
||||||
|
const bucket = byKey.get(key) ?? [];
|
||||||
|
bucket.push(row);
|
||||||
|
byKey.set(key, bucket);
|
||||||
|
}
|
||||||
|
for (const [key, rows] of byKey) {
|
||||||
|
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
|
||||||
|
map.set(key, record);
|
||||||
|
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
||||||
|
accountCache.set(key, record);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " loadShim failed:", error);
|
||||||
|
}
|
||||||
|
cache = map;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||||||
|
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||||||
|
* the account record at its known subject (`accountSubject(id)`) directly in the
|
||||||
|
* doc-shim, instead of scanning EVERY account like {@link loadShim}. Returns the
|
||||||
|
* account's record or `null` if it does not exist yet.
|
||||||
|
*
|
||||||
|
* ── Barrier-AUTHORITATIVE (the reconnection fix) ────────────────────────────
|
||||||
|
* The read targets the DOC-SHIM (`did:ng:o:...`), which resolveShimDoc opened
|
||||||
|
* through its first-`State` barrier. So a cold 0 rows here is DEFINITIVE ("account
|
||||||
|
* genuinely absent"), not ambiguous sync-lag — no account-level retry is needed or
|
||||||
|
* used. This is what replaced the old `resolveAccountReliably` / `provisionRetry`
|
||||||
|
* loop: the store-root ambiguity that forced the retry is gone once the read moves
|
||||||
|
* behind the doc-shim barrier.
|
||||||
|
*
|
||||||
|
* Cached per account (in `accountCache`); a hit skips the query entirely, so
|
||||||
|
* repeated resolves of the same account are free. `resetRegistryCache` clears it.
|
||||||
|
*/
|
||||||
|
export async function resolveAccount(id: string): Promise<AccountRecord | null> {
|
||||||
|
const key = accountKey(id);
|
||||||
|
const cached = accountCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const s = await session();
|
||||||
|
const doc = await resolveShimDoc();
|
||||||
|
// `subj` is already IRI-safe (accountSubject → escapeIri). The read is anchored to
|
||||||
|
// the doc-shim's default graph (opened through its barrier by resolveShimDoc), so
|
||||||
|
// it is authoritative. The query is bounded to this one subject.
|
||||||
|
const subj = accountSubject(id);
|
||||||
|
const query = `
|
||||||
|
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||||
|
<${subj}> a <${P.type}> ;
|
||||||
|
<${P.id}> ?id ;
|
||||||
|
<${P.docPublic}> ?docPublic ;
|
||||||
|
<${P.docProtected}> ?docProtected ;
|
||||||
|
<${P.docPrivate}> ?docPrivate .
|
||||||
|
}`;
|
||||||
|
try {
|
||||||
|
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
|
||||||
|
const rows = readBindings(result);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
logStage("resolveAccount(" + key + ") → null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// DETERMINISTIC: a corrupted shim may return SEVERAL bindings for this one
|
||||||
|
// account subject (duplicate scope-doc values from past forks). Pick the
|
||||||
|
// canonical (lexicographically-smallest) doc per scope so writer and reader
|
||||||
|
// always resolve the SAME docPublic (robustness against PAST fork residue).
|
||||||
|
const record = recordFromRows(rows, id);
|
||||||
|
accountCache.set(key, record);
|
||||||
|
logStage("resolveAccount(" + key + ") → 1 record");
|
||||||
|
return record;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " resolveAccount failed:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All known accounts (from the shim). */
|
||||||
|
export async function allAccounts(): Promise<AccountRecord[]> {
|
||||||
|
return [...(await loadShim()).values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
|
||||||
|
* canonical always-safe shape — same convention as createEntityDoc). */
|
||||||
|
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||||
|
const s = await session();
|
||||||
|
const subj = `${SHIM}:account:${escapeIri(accountKey(record.id))}`;
|
||||||
|
// `subj` is IRI-safe (escapeIri). `id` is UNTRUSTED text in a LITERAL position →
|
||||||
|
// escapeLiteral. The doc NURIs come from `ng` but are stored as literals here, so
|
||||||
|
// they are escaped as literals too (defence in depth). NO explicit `GRAPH <…>`
|
||||||
|
// wrapper: write the anchored DEFAULT graph (the `doc` anchor scopes it) — the
|
||||||
|
// canonical, always-safe shape the anchored default-graph read queries match.
|
||||||
|
const update = `
|
||||||
|
INSERT DATA {
|
||||||
|
<${subj}> a <${P.type}> ;
|
||||||
|
<${P.id}> "${escapeLiteral(record.id)}" ;
|
||||||
|
<${P.docPublic}> "${escapeLiteral(record.docPublic)}" ;
|
||||||
|
<${P.docProtected}> "${escapeLiteral(record.docProtected)}" ;
|
||||||
|
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
|
||||||
|
}`;
|
||||||
|
try {
|
||||||
|
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-flight `ensureAccount` promises, keyed by account key — so CONCURRENT
|
||||||
|
* `ensureAccount(id)` calls for the SAME account share ONE resolve-or-provision.
|
||||||
|
*
|
||||||
|
* ── Why this is load-bearing (concurrent-provision de-dup) ──────────────────
|
||||||
|
* On a fresh page over the persistent wallet, SEVERAL independent callers hit
|
||||||
|
* `ensureAccount(A)` near-simultaneously (watchShape public/protected, the container
|
||||||
|
* subscriptions, the app's owned-events effect). With the barrier-authoritative
|
||||||
|
* resolveAccount a fresh page NO LONGER mistakes sync-lag for absence — but if the
|
||||||
|
* account is GENUINELY new, N concurrent callers would still each see 0 and each
|
||||||
|
* provision a set of scope docs (an in-session fork). De-duping concurrent provisions
|
||||||
|
* collapses those N into ONE: the first caller resolves-or-provisions; every
|
||||||
|
* concurrent caller awaits the SAME promise and gets the SAME record. Not polling: a
|
||||||
|
* bounded in-memory promise map (mirrors open-repo.ts `inFlight`), cleared the instant
|
||||||
|
* it settles.
|
||||||
|
*/
|
||||||
|
const ensureInFlight = new Map<string, Promise<AccountRecord>>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure an account exists in the shim, creating its 3 scope documents on
|
* Ensure an account exists in the shim, creating its 3 scope documents on
|
||||||
* first sight. Idempotent — returns the existing record if already present.
|
* first sight. Idempotent — returns the existing record if already present.
|
||||||
|
* Concurrency-safe: concurrent calls for the same account share one provision
|
||||||
|
* (see {@link ensureInFlight}) so a fresh page never FORKS the account.
|
||||||
*/
|
*/
|
||||||
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||||
const key = accountKey(id);
|
const key = accountKey(id);
|
||||||
|
// A completed provision/resolve is cached → no query, no fork risk.
|
||||||
|
const cached = accountCache.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
// A concurrent provision for the SAME account is already running → await it,
|
||||||
|
// instead of racing a second (forking) provision. This is the anti-fork guard.
|
||||||
|
const pending = ensureInFlight.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const p = (async (): Promise<AccountRecord> => {
|
||||||
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead
|
||||||
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
// of a full-shim scan (loadShim). Off the read/write hot path entirely.
|
||||||
//
|
//
|
||||||
// ANTI-FORK (polyfill-era): open the private-store repo and await its first
|
// Barrier-AUTHORITATIVE: resolveAccount reads the doc-shim behind its first-`State`
|
||||||
// `State` push (the sync barrier) BEFORE reading the shim, so a 0-row result
|
// barrier (opened by resolveShimDoc), so a 0 here means the account is GENUINELY
|
||||||
// is DEFINITIVE rather than a lag artifact. Only after the barrier do we treat
|
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
|
||||||
// 0 rows as "genuinely new" and provision. A cache hit inside
|
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
|
||||||
// resolveAccountReliably keeps same-session resolves free and deterministic
|
const existing = await resolveAccount(id);
|
||||||
// (the cached record wins over any re-provision). Throws if the barrier timed
|
|
||||||
// out — safer than provisioning blind and creating a fork.
|
|
||||||
const existing = await resolveAccountReliably(id);
|
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const doc = await resolveShimDoc();
|
||||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||||
createDoc(),
|
createDoc(),
|
||||||
createDoc(),
|
createDoc(),
|
||||||
createDoc(),
|
createDoc(),
|
||||||
]);
|
]);
|
||||||
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
|
||||||
|
// Persist the record INTO the doc-shim (not the store-root anymore).
|
||||||
const s = await session();
|
await writeRecord(doc, record);
|
||||||
const anchor = await anchorNuri();
|
|
||||||
const subj = accountSubject(id);
|
|
||||||
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a
|
|
||||||
// trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL
|
|
||||||
// position → escapeLiteral. The doc NURIs come from `ng` but are stored as
|
|
||||||
// literals here, so they are escaped as literals too (defence in depth).
|
|
||||||
const update = `
|
|
||||||
INSERT DATA {
|
|
||||||
GRAPH <${assertNuri(anchor)}> {
|
|
||||||
<${subj}> a <${P.type}> ;
|
|
||||||
<${P.id}> "${escapeLiteral(id)}" ;
|
|
||||||
<${P.docPublic}> "${escapeLiteral(docPublic)}" ;
|
|
||||||
<${P.docProtected}> "${escapeLiteral(docProtected)}" ;
|
|
||||||
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
try {
|
|
||||||
await sparqlUpdate(s.sessionId, update, anchor, "ensureAccount");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[storeRegistry] ensureAccount persist failed:", error);
|
|
||||||
}
|
|
||||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||||||
// (so allAccounts / the fan-out see the freshly-created account too).
|
// (so allAccounts / the fan-out see the freshly-created account too).
|
||||||
accountCache.set(key, record);
|
accountCache.set(key, record);
|
||||||
cache?.set(key, record);
|
cache?.set(key, record);
|
||||||
return record;
|
return record;
|
||||||
|
})();
|
||||||
|
|
||||||
|
ensureInFlight.set(key, p);
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
ensureInFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- resolvers ------------------------------------------------------------
|
// --- resolvers ------------------------------------------------------------
|
||||||
@@ -476,12 +713,11 @@ const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
|
|||||||
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
||||||
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
||||||
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
||||||
* account→document trust root) lives in the private-store graph and is scanned on
|
* account→document trust root) is scanned on every `loadShim`; routing every inbox
|
||||||
* every `loadShim`; routing every inbox deposit into that SAME graph bloats it
|
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
|
||||||
* without bound (thousands of deposit triples across sessions), turning `loadShim`
|
* across sessions). A separate inbox document keeps the shim graph small and the
|
||||||
* into a multi-second full-graph scan. A separate inbox document keeps the shim
|
* deposits isolated. At migration this becomes the host's native per-document inbox
|
||||||
* graph small and the deposits isolated. At migration this becomes the host's
|
* and the resolution moves here.
|
||||||
* native per-document inbox and the resolution moves here.
|
|
||||||
*/
|
*/
|
||||||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
export async function resolveInboxAnchor(): Promise<Nuri> {
|
||||||
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
||||||
@@ -517,7 +753,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
|||||||
"createEntityDoc",
|
"createEntityDoc",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[storeRegistry] createEntityDoc index append failed:", error);
|
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
|
||||||
}
|
}
|
||||||
return entityNuri;
|
return entityNuri;
|
||||||
}
|
}
|
||||||
@@ -549,8 +785,9 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
|||||||
if (v) out.push(v);
|
if (v) out.push(v);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[storeRegistry] readScopeIndex read failed:", error);
|
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
|
||||||
}
|
}
|
||||||
|
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +811,20 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scope-INDEX document NURI of ONE account (`id`) for `scope` — the
|
||||||
|
* store-container document that LISTS the account's per-entity document NURIs
|
||||||
|
* (what {@link listMyEntityDocs} reads). Exposed so a reactive reader
|
||||||
|
* ({@link watchShape}) can SUBSCRIBE to this index document and re-resolve the
|
||||||
|
* entity-doc set when the index changes (a new entity created appends a NURI
|
||||||
|
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
|
||||||
|
* user's real per-scope store NURI (the container the store itself provides).
|
||||||
|
*/
|
||||||
|
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||||
|
const record = await ensureAccount(id);
|
||||||
|
return indexDocOf(record, scope);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
* The entity-document NURIs of `scope` belonging to ONE account (`id`) —
|
||||||
* the read-by-need path for one account's own entities. Bounded to a SINGLE
|
* the read-by-need path for one account's own entities. Bounded to a SINGLE
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
|
||||||
|
* one logical scope. This is the surface the consuming app will bind (phase B) with
|
||||||
|
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
|
||||||
|
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
|
||||||
|
*
|
||||||
|
* ── Why an observable, and why this exact shape ────────────────────────────
|
||||||
|
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
|
||||||
|
* natively distinguish "sync in progress" from "synced but empty". That distinction
|
||||||
|
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
|
||||||
|
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
|
||||||
|
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
|
||||||
|
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
|
||||||
|
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
|
||||||
|
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
|
||||||
|
*
|
||||||
|
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
|
||||||
|
* 1. Resolve the logical scope → the doc set: the current identity's per-entity
|
||||||
|
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS — for `public`
|
||||||
|
* only — the discovery index folded in (`discovery.readIndex`), so the app
|
||||||
|
* never orchestrates discovery to read. Faithful to the future
|
||||||
|
* `useShape(shape, 'public')`.
|
||||||
|
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
|
||||||
|
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
|
||||||
|
* fallback). `isPending` holds until the barrier is reached for the current
|
||||||
|
* doc set AND the first `readUnion` has rendered.
|
||||||
|
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
|
||||||
|
* NOT double-filter), then FILTER the union by the requested shape's `@type`
|
||||||
|
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
|
||||||
|
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
|
||||||
|
* ShapeType, not from any application concept.
|
||||||
|
*
|
||||||
|
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
||||||
|
* Reactivity is push-only (rule no-broker-polling): `subscribeDoc` on every doc in
|
||||||
|
* the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
|
||||||
|
* entity appends a NURI to the scope-index doc; announcing a public entity appends
|
||||||
|
* to the discovery index), so we ALSO subscribe to the scope-index document (and,
|
||||||
|
* for `public`, the discovery-index document): a push there re-RESOLVES the scope
|
||||||
|
* and re-keys the subscribed set. Subscriptions are idempotent — an already-followed
|
||||||
|
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
|
||||||
|
* parallel channel.
|
||||||
|
*
|
||||||
|
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
|
||||||
|
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
|
||||||
|
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
|
||||||
|
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCurrentUser } from "./polyfill";
|
||||||
|
import { ensureReposOpen, getSyncState } from "./open-repo";
|
||||||
|
import { readUnion, type UnionSubject } from "./read-model";
|
||||||
|
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||||
|
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry";
|
||||||
|
import { readIndex, indexDocNuri } from "./discovery";
|
||||||
|
import type { Nuri, Scope } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
|
||||||
|
* constraint on this predicate; we filter the read union by it.
|
||||||
|
*/
|
||||||
|
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
|
||||||
|
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
|
||||||
|
* the generic per-subject property bags of the read-model (NO application domain);
|
||||||
|
* the app maps them to its own entity types in phase B.
|
||||||
|
*/
|
||||||
|
export interface ShapeQuery<T = UnionSubject> {
|
||||||
|
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
|
||||||
|
data: T[];
|
||||||
|
/** True while the sync barrier for the current doc set is not yet reached OR the
|
||||||
|
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
|
||||||
|
isPending: boolean;
|
||||||
|
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
|
||||||
|
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
|
||||||
|
* `data: []` — the distinction this surface exists for. */
|
||||||
|
isSuccess: boolean;
|
||||||
|
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
|
||||||
|
isError: boolean;
|
||||||
|
/** The caught error when `isError`, else `undefined`. */
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
|
||||||
|
export interface ShapeObservable<T = UnionSubject> {
|
||||||
|
/** The current snapshot. STABLE across calls until it actually changes (so
|
||||||
|
* `useSyncExternalStore` does not loop): the same reference is returned until a
|
||||||
|
* state transition produces a new one. */
|
||||||
|
getSnapshot(): ShapeQuery<T>;
|
||||||
|
/** Register a change listener; returns an unsubscribe. The last listener's
|
||||||
|
* unsubscribe tears down the underlying doc subscriptions. */
|
||||||
|
subscribe(onChange: () => void): () => void;
|
||||||
|
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
|
||||||
|
* w.r.t. subscriptions; never polls. */
|
||||||
|
refetch(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
|
||||||
|
* A generated shape constrains its subject's type via a triple constraint on the
|
||||||
|
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
|
||||||
|
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
|
||||||
|
* and every subject in the doc set flows through). Purely structural — reads only
|
||||||
|
* the SHEX schema, no application domain.
|
||||||
|
*/
|
||||||
|
function shapeTypeIris(shapeType: unknown): Set<string> | null {
|
||||||
|
try {
|
||||||
|
const st = shapeType as {
|
||||||
|
shape?: string;
|
||||||
|
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
|
||||||
|
};
|
||||||
|
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
|
||||||
|
const preds = shape?.predicates ?? [];
|
||||||
|
const iris = new Set<string>();
|
||||||
|
for (const p of preds) {
|
||||||
|
if (p?.iri !== RDF_TYPE) continue;
|
||||||
|
for (const dt of p.dataTypes ?? []) {
|
||||||
|
for (const lit of dt.literals ?? []) {
|
||||||
|
if (typeof lit === "string") iris.add(lit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return iris.size > 0 ? iris : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
|
||||||
|
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
|
||||||
|
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
|
||||||
|
const types = subject.props[RDF_TYPE] ?? [];
|
||||||
|
return types.some((t) => typeIris.has(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
|
||||||
|
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
|
||||||
|
* state that still holds the barrier open is `syncing` (subscribed, first `State`
|
||||||
|
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
|
||||||
|
* best-effort, not an error). `unknown` means the injected `ng` has no
|
||||||
|
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
|
||||||
|
* a completed open it can only mean that path, so it counts as reached (opened,
|
||||||
|
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
|
||||||
|
*/
|
||||||
|
function barrierReached(docs: Nuri[]): boolean {
|
||||||
|
for (const d of docs) {
|
||||||
|
if (getSyncState(d) === "syncing") return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
|
||||||
|
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
|
||||||
|
* the full pipeline. The returned observable is inert until its first
|
||||||
|
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
|
||||||
|
* what kicks off resolution, opening and the first read; before then `getSnapshot`
|
||||||
|
* reports the initial pending snapshot.
|
||||||
|
*/
|
||||||
|
export function watchShape<T = UnionSubject>(
|
||||||
|
shapeType: unknown,
|
||||||
|
scope: Scope,
|
||||||
|
): ShapeObservable<T> {
|
||||||
|
const typeIris = shapeTypeIris(shapeType);
|
||||||
|
|
||||||
|
// The current, STABLE snapshot (same reference until a transition rebuilds it).
|
||||||
|
let snapshot: ShapeQuery<UnionSubject> = {
|
||||||
|
data: [],
|
||||||
|
isPending: true,
|
||||||
|
isSuccess: false,
|
||||||
|
isError: false,
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let started = false;
|
||||||
|
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
|
||||||
|
// Idempotent: a doc already here is not re-subscribed. Excludes the container
|
||||||
|
// (scope-index / discovery-index) subscriptions, held separately.
|
||||||
|
const docSubs = new Map<Nuri, Unsubscribe>();
|
||||||
|
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
|
||||||
|
// a push here means the doc SET may have changed → re-resolve.
|
||||||
|
const containerSubs = new Map<Nuri, Unsubscribe>();
|
||||||
|
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
|
||||||
|
let refreshToken = 0;
|
||||||
|
|
||||||
|
function emit(): void {
|
||||||
|
for (const l of listeners) {
|
||||||
|
try {
|
||||||
|
l();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[watch-shape] listener threw", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
|
||||||
|
snapshot = next;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract candidate document NURIs from an opaque discovery `ref` — every
|
||||||
|
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic:
|
||||||
|
* the app puts the entity doc NURI inside the ref it submits; we fold those docs
|
||||||
|
* into the read-set so the app need not orchestrate discovery. Non-NURI refs
|
||||||
|
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */
|
||||||
|
function nurisFromRef(ref: unknown, out: Set<Nuri>): void {
|
||||||
|
if (typeof ref === "string") {
|
||||||
|
if (ref.startsWith("did:ng:")) out.add(ref);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(ref)) {
|
||||||
|
for (const v of ref) nurisFromRef(v, out);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref && typeof ref === "object") {
|
||||||
|
for (const v of Object.values(ref)) nurisFromRef(v, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the logical scope → the current doc set (my entity docs + discovery
|
||||||
|
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
|
||||||
|
async function resolveDocs(): Promise<Nuri[]> {
|
||||||
|
const user = getCurrentUser();
|
||||||
|
const set = new Set<Nuri>();
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[watch-shape] listMyEntityDocs failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scope === "public") {
|
||||||
|
try {
|
||||||
|
for (const e of await readIndex()) nurisFromRef(e.ref, set);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[watch-shape] discovery readIndex failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to the CONTAINER documents (scope-index; discovery-index for public)
|
||||||
|
* so a change to the doc SET re-resolves. Idempotent per NURI. */
|
||||||
|
async function ensureContainerSubs(): Promise<void> {
|
||||||
|
const containers: Nuri[] = [];
|
||||||
|
const user = getCurrentUser();
|
||||||
|
if (user) {
|
||||||
|
try {
|
||||||
|
containers.push(await scopeIndexDoc(user, scope));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[watch-shape] scopeIndexDoc failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (scope === "public") {
|
||||||
|
try {
|
||||||
|
containers.push(await indexDocNuri());
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[watch-shape] indexDocNuri failed", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of containers) {
|
||||||
|
if (!c || containerSubs.has(c)) continue;
|
||||||
|
// A push on a container doc means the set may have changed → full re-resolve.
|
||||||
|
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
|
||||||
|
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
|
||||||
|
function syncDocSubs(docs: Nuri[]): void {
|
||||||
|
const wanted = new Set(docs.filter(Boolean));
|
||||||
|
for (const [nuri, unsub] of docSubs) {
|
||||||
|
if (!wanted.has(nuri)) {
|
||||||
|
try {
|
||||||
|
unsub();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
docSubs.delete(nuri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const nuri of wanted) {
|
||||||
|
if (docSubs.has(nuri)) continue;
|
||||||
|
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
|
||||||
|
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
|
||||||
|
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
|
||||||
|
let subjects: UnionSubject[];
|
||||||
|
try {
|
||||||
|
subjects = await readUnion(docs);
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== refreshToken) return;
|
||||||
|
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token !== refreshToken) return; // superseded by a newer refresh/reread
|
||||||
|
const data = subjects.filter((s) => matchesShape(s, typeIris));
|
||||||
|
const past = barrierReached(docs);
|
||||||
|
setSnapshot({
|
||||||
|
data,
|
||||||
|
isPending: !past,
|
||||||
|
isSuccess: past,
|
||||||
|
isError: false,
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
|
||||||
|
* (await the barrier), sync per-doc subs, then read + publish. */
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
const token = ++refreshToken;
|
||||||
|
try {
|
||||||
|
await ensureContainerSubs();
|
||||||
|
const docs = await resolveDocs();
|
||||||
|
if (token !== refreshToken) return;
|
||||||
|
syncDocSubs(docs);
|
||||||
|
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
|
||||||
|
await ensureReposOpen(docs);
|
||||||
|
if (token !== refreshToken) return;
|
||||||
|
await readAndPublish(docs, token);
|
||||||
|
} catch (error) {
|
||||||
|
if (token !== refreshToken) return;
|
||||||
|
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
|
||||||
|
* the set is unchanged). Reuses the docs we are subscribed to. */
|
||||||
|
async function reread(): Promise<void> {
|
||||||
|
const token = ++refreshToken;
|
||||||
|
const docs = [...docSubs.keys()];
|
||||||
|
await readAndPublish(docs, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getSnapshot(): ShapeQuery<T> {
|
||||||
|
return snapshot as unknown as ShapeQuery<T>;
|
||||||
|
},
|
||||||
|
subscribe(onChange: () => void): () => void {
|
||||||
|
listeners.add(onChange);
|
||||||
|
start();
|
||||||
|
return () => {
|
||||||
|
listeners.delete(onChange);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
// Last listener gone → tear down the underlying subscriptions. A later
|
||||||
|
// subscribe restarts a fresh cycle.
|
||||||
|
for (const u of docSubs.values()) {
|
||||||
|
try {
|
||||||
|
u();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const u of containerSubs.values()) {
|
||||||
|
try {
|
||||||
|
u();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docSubs.clear();
|
||||||
|
containerSubs.clear();
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refetch(): void {
|
||||||
|
start();
|
||||||
|
void refresh();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
|
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
|
||||||
* emit nothing to console.log.
|
* emit nothing to console.log.
|
||||||
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
|
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
|
||||||
* matching `[<identity>] READ/WRITE <nuri> (<label>)` plus row-count suffix
|
* matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
|
||||||
* on READs.
|
* FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
|
||||||
|
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
|
||||||
|
* RepoID truncated to 8 chars + ellipsis).
|
||||||
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
|
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
|
||||||
* calling code.
|
* calling code.
|
||||||
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
|
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
|
||||||
@@ -17,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||||
import { setAccessLog, enabled } from "../src/access-log";
|
import { setAccessLog, enabled, shortNuri } from "../src/access-log";
|
||||||
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
|
||||||
import {
|
import {
|
||||||
configure,
|
configure,
|
||||||
@@ -47,7 +49,6 @@ function injectFake(debugAccessLog = false) {
|
|||||||
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
|
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
|
||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
|
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
@@ -160,11 +161,12 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[0]).toMatch(/READ/);
|
expect(lines[0]).toMatch(/READ/);
|
||||||
expect(lines[0]).toMatch(/did:ng:o:q/);
|
expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
|
||||||
|
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
|
||||||
expect(lines[0]).toMatch(/myLabel/);
|
expect(lines[0]).toMatch(/myLabel/);
|
||||||
expect(lines[0]).toMatch(/→ 1 rows/); // row-count from the 1-row fake result
|
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
||||||
@@ -177,9 +179,9 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[0]).toMatch(/WRITE/);
|
expect(lines[0]).toMatch(/WRITE/);
|
||||||
expect(lines[0]).toMatch(/did:ng:o:w/);
|
expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
|
||||||
expect(lines[0]).toMatch(/writeLabel/);
|
expect(lines[0]).toMatch(/writeLabel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,10 +195,10 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[0]).toMatch(/WRITE/);
|
expect(lines[0]).toMatch(/WRITE/);
|
||||||
// The nuri is the value returned by ng.doc_create
|
// The nuri is the value returned by ng.doc_create, shortened by shortNuri.
|
||||||
expect(lines[0]).toMatch(/did:ng:o:log-doc/);
|
expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enabled() returns true when set via setAccessLog", () => {
|
it("enabled() returns true when set via setAccessLog", () => {
|
||||||
@@ -219,9 +221,9 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[bob\]/);
|
expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[0]).toMatch(/READ/);
|
expect(lines[0]).toMatch(/READ/);
|
||||||
expect(lines[0]).toMatch(/did:ng:o:env-q/);
|
expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
|
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
|
||||||
@@ -236,7 +238,7 @@ describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[charlie\]/);
|
expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[0]).toMatch(/WRITE/);
|
expect(lines[0]).toMatch(/WRITE/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,8 +263,8 @@ describe("access-log: identity follows setCurrentUser", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(2);
|
expect(lines.length).toBe(2);
|
||||||
expect(lines[0]).toMatch(/\[first-user\]/);
|
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
expect(lines[1]).toMatch(/\[second-user\]/);
|
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefix is (none) when no identity is set", async () => {
|
it("prefix is (none) when no identity is set", async () => {
|
||||||
@@ -275,6 +277,6 @@ describe("access-log: identity follows setCurrentUser", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
expect(lines[0]).toMatch(/\[\(none\)\]/);
|
expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* anti-fork.test.ts — behavioral tests for the ANTI-FORK guard in ensureAccount /
|
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
|
||||||
* resolveAccountReliably (src/store-registry.ts).
|
* polyfill-era shim (src/store-registry.ts), redesigned around the
|
||||||
|
* pointer → doc-shim indirection. Two groups, one file:
|
||||||
*
|
*
|
||||||
* The guard is: BEFORE reading the shim, open the private-store repo and await
|
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
|
||||||
* the first `State` push (the deterministic sync barrier, CONTRACT 3). After the
|
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
|
||||||
* barrier, 0 rows is DEFINITIVE (account genuinely absent) → provision exactly
|
* resolve to the SAME canonical doc every time (lexicographically-smallest
|
||||||
* once; rows present → reuse (NO-FORK). No retry loop, no polling.
|
* NURI), so the session that WROTE an entity and a fresh page that RESOLVES
|
||||||
|
* the doc never disagree. Robustness against PAST fork residue.
|
||||||
*
|
*
|
||||||
* With the unit fake `ng` (no `doc_subscribe`), `ensureRepoOpen` is a no-op
|
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
|
||||||
* (getSyncState → "unknown") and the single shim read is immediate — no lag to
|
* persistent wallet resolves the SAME account through the doc-shim's
|
||||||
* wait out, synchronous behaviour preserved.
|
* first-`State` BARRIER, with NO account-level retry. The account records
|
||||||
*
|
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
|
||||||
* Timed-out barrier: if the barrier expires without a `State`, we refuse to
|
* POINTER triple in the store-root; opening the doc-shim makes a cold read
|
||||||
* provision (throwing a clear error) rather than risk a fork.
|
* authoritative, so a genuinely-present account is found on the first read
|
||||||
|
* and never re-provisioned (no fork). This replaced the deleted
|
||||||
|
* `resolveAccountReliably` / `provisionRetry` account retry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||||
import {
|
import {
|
||||||
ensureAccount,
|
ensureAccount,
|
||||||
|
resolveAccount,
|
||||||
|
loadShim,
|
||||||
resetRegistryCache,
|
resetRegistryCache,
|
||||||
} from "../src/store-registry";
|
} from "../src/store-registry";
|
||||||
import type { RegistrySession } from "../src/store-registry";
|
import type { RegistrySession } from "../src/store-registry";
|
||||||
@@ -27,7 +33,7 @@ import {
|
|||||||
resetStoreRegistry,
|
resetStoreRegistry,
|
||||||
resetConfig,
|
resetConfig,
|
||||||
} from "../src/polyfill";
|
} from "../src/polyfill";
|
||||||
import { resetOpenedRepos, _forceOpenedSyncState } from "../src/open-repo";
|
import { resetOpenedRepos } from "../src/open-repo";
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
resetConfig();
|
resetConfig();
|
||||||
@@ -37,13 +43,17 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
|
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
|
||||||
|
const ROOT = "did:ng:PRIV-AF";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Fake ng — simple in-memory store (no doc_subscribe → fake-ng no-op path)
|
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
|
||||||
//
|
//
|
||||||
// Queries return data from the `quads` array immediately. No lag simulation:
|
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
|
||||||
// the barrier mechanism (ensureRepoOpen) is a no-op in the fake-ng path, so
|
// graph (keyed by GRAPH <ROOT>).
|
||||||
// the single shim read after "the barrier" is already authoritative.
|
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
|
||||||
|
// anchor arg = the doc-shim NURI).
|
||||||
|
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
|
||||||
|
// (the barrier).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Quad { g: string; s: string; p: string; o: string }
|
interface Quad { g: string; s: string; p: string; o: string }
|
||||||
@@ -61,14 +71,8 @@ function unescapeLiteral(s: string): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFakeNg() {
|
function makeSparqlUpdate(quads: Quad[]) {
|
||||||
const quads: Quad[] = [];
|
return mock(async (...a: unknown[]) => {
|
||||||
let docCounter = 0;
|
|
||||||
let accountQueryCount = 0;
|
|
||||||
|
|
||||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
|
||||||
|
|
||||||
const sparql_update = mock(async (...a: unknown[]) => {
|
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
const anchor = a[2] as string | undefined;
|
const anchor = a[2] as string | undefined;
|
||||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||||
@@ -95,163 +99,231 @@ function makeFakeNg() {
|
|||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sparql_query = mock(async (...a: unknown[]) => {
|
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
|
||||||
const query = a[1] as string;
|
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
|
||||||
const anchor = a[3] as string | undefined;
|
* of bindings — a corrupted shim. */
|
||||||
|
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
|
||||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
|
||||||
// Account SELECT (resolveAccount / loadShim)
|
|
||||||
accountQueryCount++;
|
|
||||||
|
|
||||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
||||||
const onlySubject = subjM ? subjM[1]! : null;
|
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
|
||||||
for (const q of quads) {
|
for (const q of quads) {
|
||||||
if (q.g !== anchor) continue;
|
if (q.g !== anchor) continue;
|
||||||
if (onlySubject !== null && q.s !== onlySubject) continue;
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||||
const rec = bySubject.get(q.s) ?? {};
|
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
|
||||||
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||||
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
|
||||||
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
|
||||||
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
|
||||||
bySubject.set(q.s, rec);
|
bySubject.set(q.s, rec);
|
||||||
}
|
}
|
||||||
const bindings = [...bySubject.values()]
|
const bindings: Array<Record<string, { value: string }>> = [];
|
||||||
.filter((r) => r.id)
|
for (const rec of bySubject.values()) {
|
||||||
.map((r) => ({
|
if (!rec.id) continue;
|
||||||
id: { value: r.id! },
|
const pubs = rec.pub.length ? rec.pub : [""];
|
||||||
docPublic: { value: r.docPublic ?? "" },
|
const prots = rec.prot.length ? rec.prot : [""];
|
||||||
docProtected: { value: r.docProtected ?? "" },
|
const privs = rec.priv.length ? rec.priv : [""];
|
||||||
docPrivate: { value: r.docPrivate ?? "" },
|
for (const pub of pubs)
|
||||||
}));
|
for (const prot of prots)
|
||||||
return { results: { bindings } };
|
for (const priv of privs)
|
||||||
|
bindings.push({
|
||||||
|
id: { value: rec.id },
|
||||||
|
docPublic: { value: pub },
|
||||||
|
docProtected: { value: prot },
|
||||||
|
docPrivate: { value: priv },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity-index SELECT
|
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
|
||||||
|
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
|
||||||
|
function makeFakeNg() {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
let docCounter = 0;
|
||||||
|
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
|
||||||
|
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
|
||||||
|
// one doc-shim account read" (no account RETRY).
|
||||||
|
const accountReadsByAnchor = new Map<string, number>();
|
||||||
|
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||||
|
const sparql_update = makeSparqlUpdate(quads);
|
||||||
|
const sparql_query = mock(async (...a: unknown[]) => {
|
||||||
|
const query = a[1] as string;
|
||||||
|
const anchor = a[3] as string | undefined;
|
||||||
|
// Pointer SELECT (store-root).
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||||
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
// Account SELECT — anchored to the doc-shim (the authoritative read).
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||||
|
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
|
||||||
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||||
|
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
|
||||||
|
}
|
||||||
const bindings = quads
|
const bindings = quads
|
||||||
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||||
.map((q) => ({ e: { value: q.o } }));
|
.map((q) => ({ e: { value: q.o } }));
|
||||||
return { results: { bindings } };
|
return { results: { bindings } };
|
||||||
});
|
});
|
||||||
|
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
|
||||||
|
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
|
||||||
|
if (typeof cb === "function") cb({ V0: { State: {} } });
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
doc_create,
|
doc_create, sparql_update, sparql_query, doc_subscribe,
|
||||||
sparql_update,
|
|
||||||
sparql_query,
|
|
||||||
_quads: quads,
|
_quads: quads,
|
||||||
getAccountQueryCount: () => accountQueryCount,
|
// Total account reads across all anchors.
|
||||||
|
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
|
||||||
|
// Account reads anchored to a did:ng:o: doc-shim.
|
||||||
|
getDocShimAccountReads: () =>
|
||||||
|
[...accountReadsByAnchor.entries()]
|
||||||
|
.filter(([g]) => g.startsWith("did:ng:o:"))
|
||||||
|
.reduce((a, [, n]) => a + n, 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inject(fakeNg: ReturnType<typeof makeFakeNg>) {
|
function inject(
|
||||||
// No doc_subscribe → ensureRepoOpen is a no-op (fake-ng path).
|
fakeNg: unknown,
|
||||||
|
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
|
||||||
|
) {
|
||||||
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
|
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
|
||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u) => u.trim().toLowerCase(),
|
normalizeId: (u) => u.trim().toLowerCase(),
|
||||||
// provisionRetry is now unused by the barrier mechanism; kept for API compat.
|
pointerGuard,
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
resetOpenedRepos();
|
resetOpenedRepos();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// (1) Deterministic resolution over fork residue (in the doc-shim)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("anti-fork: barrier-first resolveAccountReliably / ensureAccount", () => {
|
describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(a) NO-FORK: account already in shim → reused, 0 new doc_create", async () => {
|
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
|
||||||
// Provision an account in an initial "session" (quads are written).
|
|
||||||
const fakeNg = makeFakeNg();
|
const fakeNg = makeFakeNg();
|
||||||
inject(fakeNg);
|
inject(fakeNg);
|
||||||
const first = await ensureAccount("LauraBarrier");
|
const docShim = "did:ng:o:shimdoc";
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
|
||||||
|
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
||||||
|
const subj = "urn:ng-eventually:shim:account:dupuser";
|
||||||
|
const dupPublics = [
|
||||||
|
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a",
|
||||||
|
];
|
||||||
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
|
||||||
|
for (const p of dupPublics)
|
||||||
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
|
||||||
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
|
||||||
|
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
|
||||||
|
|
||||||
// Simulate a fresh session: clear caches but keep quads intact (same fakeNg).
|
const r1 = await resolveAccount("dupuser");
|
||||||
// In the barrier model the single post-barrier read finds the account immediately.
|
resetRegistryCache();
|
||||||
|
const r2 = await resolveAccount("dupuser");
|
||||||
|
const viaShim = (await loadShim()).get("dupuser");
|
||||||
|
|
||||||
|
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
|
||||||
|
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
|
||||||
|
expect(r2?.docPublic).toBe(r1?.docPublic);
|
||||||
|
expect(viaShim?.docPublic).toBe(r1?.docPublic);
|
||||||
|
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
|
||||||
|
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
|
||||||
|
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
||||||
|
|
||||||
|
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
|
||||||
|
const fakeNg = makeFakeNg();
|
||||||
|
inject(fakeNg);
|
||||||
|
// First login: provisions the account (1 doc-shim + 3 scope docs).
|
||||||
|
const first = await ensureAccount("LauraBarrier");
|
||||||
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
|
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
|
||||||
|
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
resetOpenedRepos();
|
resetOpenedRepos();
|
||||||
|
|
||||||
const second = await ensureAccount("LauraBarrier");
|
const second = await ensureAccount("LauraBarrier");
|
||||||
|
|
||||||
// ANTI-FORK: same scope docs returned, no new provisioning
|
|
||||||
expect(second.docPublic).toBe(first.docPublic);
|
expect(second.docPublic).toBe(first.docPublic);
|
||||||
expect(second.docProtected).toBe(first.docProtected);
|
expect(second.docProtected).toBe(first.docProtected);
|
||||||
expect(second.docPrivate).toBe(first.docPrivate);
|
expect(second.docPrivate).toBe(first.docPrivate);
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3); // still exactly 3, not 6
|
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
|
||||||
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(b) genuinely new account (fake returns 0 rows) → provisioned exactly once (3 doc_create)", async () => {
|
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
|
||||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows on any account query
|
// Seed session 1; capture the account NURIs + the persistent quads.
|
||||||
inject(fakeNg);
|
const seed = makeFakeNg();
|
||||||
|
inject(seed);
|
||||||
|
const orig = await ensureAccount("BarrierUser");
|
||||||
|
expect(seed.doc_create).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
|
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
|
||||||
|
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
|
||||||
|
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
|
||||||
|
// it is NOT used for the account.
|
||||||
|
const reconnect = makeFakeNg();
|
||||||
|
reconnect._quads.push(...seed._quads);
|
||||||
|
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
|
||||||
|
|
||||||
|
const resolved = await ensureAccount("BarrierUser");
|
||||||
|
|
||||||
|
expect(resolved.docPublic).toBe(orig.docPublic);
|
||||||
|
expect(resolved.docProtected).toBe(orig.docProtected);
|
||||||
|
expect(resolved.docPrivate).toBe(orig.docPrivate);
|
||||||
|
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
|
||||||
|
// Barrier-authoritative: found on the FIRST doc-shim read.
|
||||||
|
expect(reconnect.getDocShimAccountReads()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
|
||||||
|
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
|
||||||
|
const fakeNg = makeFakeNg();
|
||||||
|
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
|
||||||
|
const docShim = "did:ng:o:preexisting-shim";
|
||||||
|
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
|
||||||
|
|
||||||
const rec = await ensureAccount("BrandNewUser");
|
const rec = await ensureAccount("BrandNewUser");
|
||||||
|
|
||||||
// Exactly 3 doc_create calls (1 set of scope docs, no fork)
|
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
||||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
|
||||||
// Single read — no retry loop
|
expect(fakeNg.getDocShimAccountReads()).toBe(1);
|
||||||
expect(fakeNg.getAccountQueryCount()).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(c) idempotence within a session: calling ensureAccount twice never creates 2 sets", async () => {
|
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
|
||||||
|
const fakeNg = makeFakeNg();
|
||||||
|
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
|
||||||
|
|
||||||
|
const rec = await ensureAccount("SyncUser");
|
||||||
|
|
||||||
|
// 1 doc-shim + 3 scope docs.
|
||||||
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
|
||||||
|
expect(fakeNg.getDocShimAccountReads()).toBe(1);
|
||||||
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
|
||||||
const fakeNg = makeFakeNg();
|
const fakeNg = makeFakeNg();
|
||||||
inject(fakeNg);
|
inject(fakeNg);
|
||||||
|
|
||||||
const a = await ensureAccount("SameUser");
|
const a = await ensureAccount("SameUser");
|
||||||
const b = await ensureAccount("SameUser");
|
const b = await ensureAccount("SameUser");
|
||||||
|
|
||||||
expect(b).toEqual(a);
|
expect(b).toEqual(a);
|
||||||
// Must still be exactly 3 (not 6)
|
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(d) fake-ng no-op barrier path: single shim read, no doc_subscribe calls", async () => {
|
|
||||||
// The fake ng has no doc_subscribe → ensureRepoOpen is a no-op (getSyncState → "unknown").
|
|
||||||
// resolveAccountReliably must proceed to the single read without waiting or throwing.
|
|
||||||
const fakeNg = makeFakeNg(); // no doc_subscribe
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
// Provision once, then verify a second resolve (fresh cache) finds it immediately.
|
|
||||||
const first = await ensureAccount("FakeNgUser");
|
|
||||||
resetRegistryCache();
|
|
||||||
resetOpenedRepos();
|
|
||||||
|
|
||||||
const second = await ensureAccount("FakeNgUser");
|
|
||||||
|
|
||||||
// Same docs reused (no fork), exactly 3 total doc_create across both calls
|
|
||||||
expect(second.docPublic).toBe(first.docPublic);
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
|
|
||||||
// The account query was called exactly twice (once per ensureAccount, no retries)
|
|
||||||
expect(fakeNg.getAccountQueryCount()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(e) timed-out barrier: resolveAccountReliably throws rather than provisioning", async () => {
|
|
||||||
// Simulate: the private store nuri is already in the `opened` set but with
|
|
||||||
// sync state "timed-out" (forced via _forceOpenedSyncState so the test
|
|
||||||
// does not have to wait 8s for the real OPEN_TIMEOUT_MS to fire).
|
|
||||||
const fakeNg = makeFakeNg(); // empty quads → 0 rows
|
|
||||||
inject(fakeNg);
|
|
||||||
|
|
||||||
// Force the private-store nuri ("did:ng:PRIV-AF") into timed-out state.
|
|
||||||
// resolveAccountReliably calls anchorNuri() → `did:ng:${privateStoreId}`.
|
|
||||||
_forceOpenedSyncState("did:ng:PRIV-AF", "timed-out");
|
|
||||||
|
|
||||||
// ensureAccount must throw (conservative anti-fork guard: do not provision blind)
|
|
||||||
await expect(ensureAccount("TimedOutUser")).rejects.toThrow(
|
|
||||||
/sync barrier timed out/,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must NOT have created any scope docs (refusing to provision)
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
|
||||||
|
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
|
||||||
|
*
|
||||||
|
* ── The gap this pins ──────────────────────────────────────────────────────
|
||||||
|
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
|
||||||
|
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
|
||||||
|
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
|
||||||
|
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
|
||||||
|
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
|
||||||
|
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
|
||||||
|
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
|
||||||
|
*
|
||||||
|
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
|
||||||
|
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
|
||||||
|
* shim — the same open-before-read guard `readScopeIndex` already applies to its
|
||||||
|
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
|
||||||
|
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
|
||||||
|
*
|
||||||
|
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
|
||||||
|
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/store-registry";
|
||||||
|
import { resetOpenedRepos } from "../src/open-repo";
|
||||||
|
import {
|
||||||
|
configure,
|
||||||
|
configureStoreRegistry,
|
||||||
|
resetStoreRegistry,
|
||||||
|
resetConfig,
|
||||||
|
} from "../src/polyfill";
|
||||||
|
|
||||||
|
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
||||||
|
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
resetConfig();
|
||||||
|
resetStoreRegistry();
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Quad { g: string; s: string; p: string; o: string }
|
||||||
|
|
||||||
|
function unescapeLiteral(s: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
if (s[i] === "\\" && i + 1 < s.length) {
|
||||||
|
const next = s[++i];
|
||||||
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||||
|
} else out += s[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
|
||||||
|
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
|
||||||
|
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
|
||||||
|
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
|
||||||
|
* `State` so `ensureRepoOpen` crosses the barrier.
|
||||||
|
*/
|
||||||
|
function makeColdAnchorNg() {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
let docCounter = 0;
|
||||||
|
const opened = new Set<string>();
|
||||||
|
let anchorSubscribes = 0;
|
||||||
|
|
||||||
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||||
|
|
||||||
|
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||||
|
if (nuri === ANCHOR) anchorSubscribes += 1;
|
||||||
|
opened.add(nuri);
|
||||||
|
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
|
||||||
|
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
||||||
|
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
|
||||||
|
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
|
||||||
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||||
|
let g: string;
|
||||||
|
let body: string;
|
||||||
|
if (gm) {
|
||||||
|
g = gm[1]!;
|
||||||
|
body = gm[2]!;
|
||||||
|
} else {
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
g = anchor;
|
||||||
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
|
}
|
||||||
|
const sm = body.match(/<([^>]+)>/);
|
||||||
|
if (!sm) return undefined;
|
||||||
|
const s = sm[1]!;
|
||||||
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||||
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = pairRe.exec(after)) !== null) {
|
||||||
|
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||||
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||||
|
quads.push({ g, s, p, o });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
|
||||||
|
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
|
||||||
|
// Pointer SELECT (store-root -> doc-shim).
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||||
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
const subjM = query.match(
|
||||||
|
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||||
|
);
|
||||||
|
const onlySubject = subjM ? subjM[1]! : null;
|
||||||
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
|
for (const q of quads) {
|
||||||
|
if (q.g !== anchor) continue;
|
||||||
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||||
|
const rec = bySubject.get(q.s) ?? {};
|
||||||
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||||
|
bySubject.set(q.s, rec);
|
||||||
|
}
|
||||||
|
const bindings = [...bySubject.values()]
|
||||||
|
.filter((r) => r.id)
|
||||||
|
.map((r) => ({
|
||||||
|
id: { value: r.id! },
|
||||||
|
docPublic: { value: r.docPublic ?? "" },
|
||||||
|
docProtected: { value: r.docProtected ?? "" },
|
||||||
|
docPrivate: { value: r.docPrivate ?? "" },
|
||||||
|
}));
|
||||||
|
return { results: { bindings } };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc_create, doc_subscribe, sparql_update, sparql_query,
|
||||||
|
_quads: quads,
|
||||||
|
anchorSubscribeCount: () => anchorSubscribes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
|
||||||
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
|
configureStoreRegistry({
|
||||||
|
getSession: async () => SESSION,
|
||||||
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
|
});
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("cold-start anchor heal", () => {
|
||||||
|
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
|
||||||
|
const ng = makeColdAnchorNg();
|
||||||
|
inject(ng);
|
||||||
|
|
||||||
|
// Without the open-before-shim heal, the read AND the provision write would both
|
||||||
|
// throw RepoNotFound on the cold anchor and the account would never persist.
|
||||||
|
const rec = await ensureAccount("@cold-alice");
|
||||||
|
expect(rec.docPublic).toBeTruthy();
|
||||||
|
expect(rec.docProtected).toBeTruthy();
|
||||||
|
expect(rec.docPrivate).toBeTruthy();
|
||||||
|
|
||||||
|
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
|
||||||
|
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
|
||||||
|
const ng = makeColdAnchorNg();
|
||||||
|
inject(ng);
|
||||||
|
|
||||||
|
const first = await ensureAccount("@cold-bob");
|
||||||
|
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
|
||||||
|
resetRegistryCache();
|
||||||
|
const again = await ensureAccount("@cold-bob");
|
||||||
|
expect(again.docPublic).toBe(first.docPublic);
|
||||||
|
expect(again.docProtected).toBe(first.docProtected);
|
||||||
|
expect(again.docPrivate).toBe(first.docPrivate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
|
||||||
|
const ng = makeColdAnchorNg();
|
||||||
|
inject(ng);
|
||||||
|
const g = await resolveWriteGraph("@cold-carol", "protected");
|
||||||
|
expect(g).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,11 +119,18 @@ function makeFakeNg() {
|
|||||||
const sparql_query = mock(async (...a: unknown[]) => {
|
const sparql_query = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
const anchor = a[3] as string | undefined;
|
const anchor = a[3] as string | undefined;
|
||||||
// Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and
|
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||||
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one
|
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||||
// subject — honour that subject filter so the bounded query is O(1)/exact.
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||||
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
|
||||||
|
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||||
|
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
|
||||||
if (query.includes(`<${SHIM}:id>`)) {
|
if (query.includes(`<${SHIM}:id>`)) {
|
||||||
const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
|
||||||
const onlySubject = subjM ? subjM[1]! : null;
|
const onlySubject = subjM ? subjM[1]! : null;
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
for (const q of quads) {
|
for (const q of quads) {
|
||||||
@@ -188,8 +195,6 @@ function inject() {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
@@ -203,8 +208,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
||||||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
||||||
// ensureAccount('@index') created its 3 scope docs.
|
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
|
||||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||||
// The deposit landed in the @index public document (its inbox).
|
// The deposit landed in the @index public document (its inbox).
|
||||||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
||||||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
(c[1] as string).includes(`${INBOX}:Deposit`),
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function inject() {
|
|||||||
const ng = makeFakeNg();
|
const ng = makeFakeNg();
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
configureStoreRegistry({ getSession: async () => SESSION });
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function inject() {
|
|||||||
};
|
};
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim(), provisionRetry: { attempts: 1 } });
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
resetCaps();
|
resetCaps();
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +150,6 @@ describe("ensureRepoOpen", () => {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Must not throw; nuri is added to opened Set (guard skips subscribe)
|
// Must not throw; nuri is added to opened Set (guard skips subscribe)
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
@@ -99,8 +97,6 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
|
||||||
normalizeId: (u: string) => u,
|
normalizeId: (u: string) => u,
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
|
||||||
|
|||||||
@@ -112,12 +112,18 @@ function makeFakeNg() {
|
|||||||
const sparql_query = mock(async (...a: unknown[]) => {
|
const sparql_query = mock(async (...a: unknown[]) => {
|
||||||
const query = a[1] as string;
|
const query = a[1] as string;
|
||||||
const anchor = a[3] as string | undefined;
|
const anchor = a[3] as string | undefined;
|
||||||
|
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||||
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||||
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan
|
// Account SELECT, anchored to the doc-shim's default graph (records live in the
|
||||||
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
|
||||||
// <Account>`) which binds one subject — honour that subject so the bounded
|
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
|
||||||
// query returns exactly that account (or nothing).
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||||
const subjM = query.match(/GRAPH <[^>]+>\s*\{\s*<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
||||||
const onlySubject = subjM ? subjM[1]! : null;
|
const onlySubject = subjM ? subjM[1]! : null;
|
||||||
const bySubject = new Map<string, Record<string, string>>();
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
for (const q of quads) {
|
for (const q of quads) {
|
||||||
@@ -158,8 +164,6 @@ function inject() {
|
|||||||
configureStoreRegistry({
|
configureStoreRegistry({
|
||||||
getSession: async () => SESSION,
|
getSession: async () => SESSION,
|
||||||
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
return ng;
|
return ng;
|
||||||
@@ -170,22 +174,53 @@ beforeEach(() => {
|
|||||||
fake = inject();
|
fake = inject();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
|
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
|
||||||
const rec = await ensureAccount("Alice");
|
const rec = await ensureAccount("Alice");
|
||||||
expect(rec.id).toBe("Alice");
|
expect(rec.id).toBe("Alice");
|
||||||
expect(fake.doc_create).toHaveBeenCalledTimes(3);
|
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
|
||||||
|
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||||
expect(rec.docProtected).not.toBe(rec.docPublic);
|
expect(rec.docProtected).not.toBe(rec.docPublic);
|
||||||
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
expect(rec.docPrivate).not.toBe(rec.docProtected);
|
||||||
// Persisted into the shim anchor graph (did:ng:PRIV).
|
// The pointer (store-root -> doc-shim) was written into the store-root graph.
|
||||||
expect(fake.sparql_update.mock.calls[0]![2]).toBe("did:ng:PRIV");
|
const pointerWrite = fake.sparql_update.mock.calls.find(
|
||||||
|
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
|
||||||
|
);
|
||||||
|
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
|
||||||
|
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
|
||||||
|
const recordWrite = fake.sparql_update.mock.calls.find(
|
||||||
|
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
|
||||||
|
);
|
||||||
|
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
|
||||||
const a = await ensureAccount("Alice");
|
const a = await ensureAccount("Alice");
|
||||||
const b = await ensureAccount("@alice");
|
const b = await ensureAccount("@alice");
|
||||||
expect(b).toEqual(a);
|
expect(b).toEqual(a);
|
||||||
expect(fake.doc_create).toHaveBeenCalledTimes(3); // not 6
|
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
|
||||||
|
// The reconnection FORK: several callers (watchShape public+protected, the
|
||||||
|
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
|
||||||
|
// the shim has synced, so each reads 0 rows and independently provisions a new
|
||||||
|
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
|
||||||
|
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
|
||||||
|
// The in-flight de-dup collapses N concurrent provisions into ONE.
|
||||||
|
const results = await Promise.all([
|
||||||
|
ensureAccount("Bob"),
|
||||||
|
ensureAccount("Bob"),
|
||||||
|
ensureAccount("@bob"),
|
||||||
|
ensureAccount("BOB"),
|
||||||
|
ensureAccount("bob"),
|
||||||
|
]);
|
||||||
|
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
|
||||||
|
// resolution too) — 4 total, not 5×3.
|
||||||
|
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||||
|
// Every caller got the SAME record (same docs), so writer/reader can never
|
||||||
|
// disagree on the canonical scope doc.
|
||||||
|
for (const r of results) expect(r).toEqual(results[0]!);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
||||||
@@ -215,8 +250,6 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
|||||||
protectedStoreId: "PROT",
|
protectedStoreId: "PROT",
|
||||||
publicStoreId: "PUB",
|
publicStoreId: "PUB",
|
||||||
}),
|
}),
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
|
||||||
provisionRetry: { attempts: 1 },
|
|
||||||
});
|
});
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||||
@@ -363,10 +396,10 @@ test("normalizeId defaults to trim when not provided", async () => {
|
|||||||
const ng = makeFakeNg();
|
const ng = makeFakeNg();
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
configureStoreRegistry({ getSession: async () => SESSION });
|
||||||
resetRegistryCache();
|
resetRegistryCache();
|
||||||
const a = await ensureAccount(" Ivy ");
|
const a = await ensureAccount(" Ivy ");
|
||||||
const b = await ensureAccount("Ivy"); // trimmed key matches
|
const b = await ensureAccount("Ivy"); // trimmed key matches
|
||||||
expect(b).toEqual(a);
|
expect(b).toEqual(a);
|
||||||
expect(ng.doc_create).toHaveBeenCalledTimes(3);
|
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function inject(failFor?: Set<string>) {
|
|||||||
const ng = makeFakeNg(failFor);
|
const ng = makeFakeNg(failFor);
|
||||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||||
configureStoreRegistry({ getSession: async () => SESSION, provisionRetry: { attempts: 1 } });
|
configureStoreRegistry({ getSession: async () => SESSION });
|
||||||
return ng;
|
return ng;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/**
|
||||||
|
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
|
||||||
|
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
|
||||||
|
*
|
||||||
|
* The fake emulates just enough of the broker:
|
||||||
|
* - `doc_create` mints monotonic doc NURIs.
|
||||||
|
* - `sparql_update` parses the shim account writes + the per-entity index
|
||||||
|
* `contains` append + arbitrary anchored triple writes into an in-memory quad
|
||||||
|
* store (same tolerant parser shape as store-registry.test / read-model.test).
|
||||||
|
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
|
||||||
|
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
|
||||||
|
* the anchor graph.
|
||||||
|
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
|
||||||
|
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
|
||||||
|
* `State` is fired only when the TEST releases it (`fireState`) — so we can
|
||||||
|
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
|
||||||
|
* subscribed doc fires a `Patch` push (reactivity).
|
||||||
|
*
|
||||||
|
* These prove the four distinctions the surface exists for:
|
||||||
|
* (a) isPending at first, isSuccess after the first State (barrier);
|
||||||
|
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
|
||||||
|
* (c) a write then push → data updates (reactivity, no polling);
|
||||||
|
* (d) timed-out → isSuccess (best-effort), NOT isError.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
|
||||||
|
import { watchShape } from "../src/watch-shape";
|
||||||
|
import {
|
||||||
|
configure,
|
||||||
|
configureStoreRegistry,
|
||||||
|
resetStoreRegistry,
|
||||||
|
resetConfig,
|
||||||
|
setCurrentUser,
|
||||||
|
} from "../src/polyfill";
|
||||||
|
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
|
||||||
|
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/open-repo";
|
||||||
|
|
||||||
|
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||||
|
const FP = "http://festipod.org/";
|
||||||
|
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
|
||||||
|
|
||||||
|
interface Quad { g: string; s: string; p: string; o: string }
|
||||||
|
|
||||||
|
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
|
||||||
|
function unescapeLiteral(s: string): string {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
if (s[i] === "\\" && i + 1 < s.length) {
|
||||||
|
const next = s[++i];
|
||||||
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
||||||
|
} else out += s[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubRec { nuri: string; cb: (r: unknown) => void }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
|
||||||
|
* fresh subscription does NOT auto-fire its `State` — the test fires it via
|
||||||
|
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
|
||||||
|
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
|
||||||
|
*/
|
||||||
|
function makeFake(opts?: { holdState?: boolean }) {
|
||||||
|
const quads: Quad[] = [];
|
||||||
|
let docCounter = 0;
|
||||||
|
const subs: SubRec[] = [];
|
||||||
|
const hold = opts?.holdState ?? false;
|
||||||
|
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
|
||||||
|
const released = new Set<string>();
|
||||||
|
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
|
||||||
|
// (the store repo is bootstrapped at connect), so its barrier `State` is always
|
||||||
|
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
|
||||||
|
// before touching the shim — pre-release it here so `holdState` (which gates the
|
||||||
|
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
|
||||||
|
// real invariant the production heal relies on.
|
||||||
|
released.add(`did:ng:${SESSION.privateStoreId}`);
|
||||||
|
let releaseEverything = false;
|
||||||
|
|
||||||
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
||||||
|
|
||||||
|
const sparql_update = mock(async (...a: unknown[]) => {
|
||||||
|
const query = a[1] as string;
|
||||||
|
const anchor = a[2] as string | undefined;
|
||||||
|
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||||
|
let g: string;
|
||||||
|
let body: string;
|
||||||
|
if (gm) {
|
||||||
|
g = gm[1]!;
|
||||||
|
body = gm[2]!;
|
||||||
|
} else {
|
||||||
|
if (!anchor) return undefined;
|
||||||
|
g = anchor;
|
||||||
|
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
||||||
|
}
|
||||||
|
const sm = body.match(/<([^>]+)>/);
|
||||||
|
if (!sm) return undefined;
|
||||||
|
const s = sm[1]!;
|
||||||
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
||||||
|
while ((m = pairRe.exec(after)) !== null) {
|
||||||
|
const p = m[1] ?? "urn:ng-eventually:shim:Account";
|
||||||
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||||
|
quads.push({ g, s, p, o });
|
||||||
|
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
|
||||||
|
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
|
||||||
|
// `State` is immediately available. Pre-release it so `holdState` (which gates the
|
||||||
|
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
|
||||||
|
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
|
||||||
|
if (p === "urn:ng-eventually:shim:shimDoc") {
|
||||||
|
released.add(o);
|
||||||
|
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A write to a subscribed doc fires a Patch push (reactivity signal).
|
||||||
|
for (const sub of subs) {
|
||||||
|
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sparql_query = mock(async (...a: unknown[]) => {
|
||||||
|
const query = a[1] as string;
|
||||||
|
const anchor = a[3] as string | undefined;
|
||||||
|
// Pointer SELECT (store-root -> doc-shim).
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
|
||||||
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||||
|
const subjM = query.match(
|
||||||
|
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
|
||||||
|
);
|
||||||
|
const onlySubject = subjM ? subjM[1]! : null;
|
||||||
|
const bySubject = new Map<string, Record<string, string>>();
|
||||||
|
for (const q of quads) {
|
||||||
|
if (q.g !== anchor) continue;
|
||||||
|
if (onlySubject !== null && q.s !== onlySubject) continue;
|
||||||
|
const rec = bySubject.get(q.s) ?? {};
|
||||||
|
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
|
||||||
|
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
|
||||||
|
bySubject.set(q.s, rec);
|
||||||
|
}
|
||||||
|
const bindings = [...bySubject.values()]
|
||||||
|
.filter((r) => r.id)
|
||||||
|
.map((r) => ({
|
||||||
|
id: { value: r.id! },
|
||||||
|
docPublic: { value: r.docPublic ?? "" },
|
||||||
|
docProtected: { value: r.docProtected ?? "" },
|
||||||
|
docPrivate: { value: r.docPrivate ?? "" },
|
||||||
|
}));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
if (query.includes("<urn:ng-eventually:shim:contains>")) {
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
|
||||||
|
.map((q) => ({ e: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
}
|
||||||
|
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
|
||||||
|
const bindings = quads
|
||||||
|
.filter((q) => q.g === anchor)
|
||||||
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
|
||||||
|
return { results: { bindings } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc_subscribe = mock(
|
||||||
|
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
|
||||||
|
subs.push({ nuri, cb });
|
||||||
|
// Platform pushes TabInfo FIRST (never the barrier).
|
||||||
|
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
|
||||||
|
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
|
||||||
|
// released (so a doc subscribed AFTER a release still crosses the barrier).
|
||||||
|
if (!hold || releaseEverything || released.has(nuri)) {
|
||||||
|
setTimeout(() => cb({ V0: { State: {} } }), 0);
|
||||||
|
}
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
|
||||||
|
function fireState(nuri: string): void {
|
||||||
|
released.add(nuri);
|
||||||
|
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
|
||||||
|
}
|
||||||
|
/** Release the barrier for EVERY doc, present and future. */
|
||||||
|
function releaseAll(): void {
|
||||||
|
releaseEverything = true;
|
||||||
|
for (const sub of subs) sub.cb({ V0: { State: {} } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc_create,
|
||||||
|
sparql_update,
|
||||||
|
sparql_query,
|
||||||
|
doc_subscribe,
|
||||||
|
_quads: quads,
|
||||||
|
fireState,
|
||||||
|
releaseAll,
|
||||||
|
subs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inject(ng: ReturnType<typeof makeFake>) {
|
||||||
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||||
|
configureStoreRegistry({
|
||||||
|
getSession: async () => SESSION,
|
||||||
|
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
|
||||||
|
});
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||||
|
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
|
||||||
|
ng._quads.push({ g: doc, s: doc, p, o });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => new Promise((r) => setTimeout(r, 5));
|
||||||
|
|
||||||
|
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
|
||||||
|
const EventShape = {
|
||||||
|
shape: `${FP}EventShape`,
|
||||||
|
schema: {
|
||||||
|
[`${FP}EventShape`]: {
|
||||||
|
iri: `${FP}EventShape`,
|
||||||
|
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setCurrentUser(null);
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
resetConfig();
|
||||||
|
resetStoreRegistry();
|
||||||
|
resetRegistryCache();
|
||||||
|
resetOpenedRepos();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("watchShape", () => {
|
||||||
|
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
|
||||||
|
const ng = makeFake({ holdState: true });
|
||||||
|
inject(ng);
|
||||||
|
setCurrentUser("alice");
|
||||||
|
// One protected entity doc for alice, carrying an Event triple.
|
||||||
|
const doc = await createEntityDoc("alice", "protected");
|
||||||
|
seed(ng, doc, TYPE, `${FP}Event`);
|
||||||
|
seed(ng, doc, `${FP}title`, "Alpha");
|
||||||
|
|
||||||
|
const obs = watchShape(EventShape, "protected");
|
||||||
|
let notes = 0;
|
||||||
|
const unsub = obs.subscribe(() => {
|
||||||
|
notes += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before the barrier: pending, no data.
|
||||||
|
await tick();
|
||||||
|
expect(obs.getSnapshot().isPending).toBe(true);
|
||||||
|
expect(obs.getSnapshot().isSuccess).toBe(false);
|
||||||
|
expect(obs.getSnapshot().data).toEqual([]);
|
||||||
|
|
||||||
|
// Release the barrier for every opened doc (present + future) → synced.
|
||||||
|
ng.releaseAll();
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isPending).toBe(false);
|
||||||
|
expect(snap.isSuccess).toBe(true);
|
||||||
|
expect(snap.isError).toBe(false);
|
||||||
|
expect(snap.data.length).toBe(1);
|
||||||
|
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
|
||||||
|
expect(notes).toBeGreaterThan(0);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
|
||||||
|
const ng = makeFake(); // auto-fires State → synced immediately
|
||||||
|
inject(ng);
|
||||||
|
setCurrentUser("bob");
|
||||||
|
// bob has NO entity docs in this scope — the scope is genuinely empty.
|
||||||
|
|
||||||
|
const obs = watchShape(EventShape, "protected");
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isPending).toBe(false);
|
||||||
|
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
|
||||||
|
expect(snap.isError).toBe(false);
|
||||||
|
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(c) a write then push updates data (reactivity, no polling)", async () => {
|
||||||
|
const ng = makeFake(); // synced immediately
|
||||||
|
inject(ng);
|
||||||
|
setCurrentUser("carol");
|
||||||
|
const doc = await createEntityDoc("carol", "protected");
|
||||||
|
seed(ng, doc, TYPE, `${FP}Event`);
|
||||||
|
seed(ng, doc, `${FP}title`, "One");
|
||||||
|
|
||||||
|
const obs = watchShape(EventShape, "protected");
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(obs.getSnapshot().data.length).toBe(1);
|
||||||
|
|
||||||
|
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
|
||||||
|
// doc. Because a new doc must appear in the set, write into the scope-INDEX
|
||||||
|
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
|
||||||
|
const doc2 = await createEntityDoc("carol", "protected");
|
||||||
|
seed(ng, doc2, TYPE, `${FP}Event`);
|
||||||
|
seed(ng, doc2, `${FP}title`, "Two");
|
||||||
|
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const titles = obs
|
||||||
|
.getSnapshot()
|
||||||
|
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
|
||||||
|
.sort();
|
||||||
|
expect(titles).toEqual(["One", "Two"]);
|
||||||
|
// No setInterval anywhere — reactivity was push-driven.
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
|
||||||
|
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
|
||||||
|
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
|
||||||
|
// a few ms so this is fast, and assert the barrier is genuinely reached via
|
||||||
|
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
|
||||||
|
// isSuccess, never isError.
|
||||||
|
const ng = makeFake({ holdState: true }); // State is never released
|
||||||
|
inject(ng);
|
||||||
|
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
|
||||||
|
setCurrentUser("dave");
|
||||||
|
const doc = await createEntityDoc("dave", "protected");
|
||||||
|
seed(ng, doc, TYPE, `${FP}Event`);
|
||||||
|
seed(ng, doc, `${FP}title`, "Timed");
|
||||||
|
|
||||||
|
const obs = watchShape(EventShape, "protected");
|
||||||
|
const unsub = obs.subscribe(() => {});
|
||||||
|
await tick();
|
||||||
|
// Before the fallback fires: still pending (subscribed, no State).
|
||||||
|
expect(obs.getSnapshot().isPending).toBe(true);
|
||||||
|
|
||||||
|
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// The entity doc's barrier resolved via timed-out (never a State).
|
||||||
|
expect(getSyncState(doc)).toBe("timed-out");
|
||||||
|
const snap = obs.getSnapshot();
|
||||||
|
expect(snap.isError).toBe(false);
|
||||||
|
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
|
||||||
|
expect(snap.isPending).toBe(false);
|
||||||
|
// The data still read (best-effort): the doc's triples resolved.
|
||||||
|
expect(snap.data.length).toBe(1);
|
||||||
|
unsub();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user