chore(client): retirer la migration legacy + alléger les logs d'accès
- Migration des comptes legacy supprimée (migrateLegacyRecords + garde migratedInto + call-sites). Un wallet pré-fix (records store-root, pas de pointeur) provisionne simplement un doc-shim frais; contenu legacy ignoré (voulu, données = dev). La résolution barrière-autoritative + anti-fork (resolvePointer/ensureRepoOpen/ canonicalDoc/ensureInFlight/pointerGuard) est inchangée. - Logs d'accès SDK préfixés [polyfill] + NURI tronqué via shortNuri() (retire did:ng:o: et :v:…, garde 8 chars) → moins verbeux. Ex: [polyfill] [user1] READ vDlwbZio… (resolvePointer) → 1 triple-rows Tests: bun test unit 126/0. Docs (nextgraph-current-state/simulation/migration-guide) mis à jour (migration legacy retirée du modèle décrit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,8 +47,8 @@ The `sharedWalletShim` (account → 3 scope-document NURIs, held in a subscribab
|
|||||||
doc-shim reached via a write-once pointer in the store-root — see
|
doc-shim reached via a write-once pointer in the store-root — see
|
||||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The pointer → doc-shim
|
||||||
indirection*) has no target equivalent — the target has no central directory. Remove
|
indirection*) has no target equivalent — the target has no central directory. Remove
|
||||||
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim +
|
it entirely: `store-registry.ts`, `configureStoreRegistry`, the pointer + doc-shim
|
||||||
migration SPARQL, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
resolution, and the `pointerGuard` dep. Cross-wallet reads replace the fan-out;
|
||||||
per-user wallets replace the shared one.
|
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
|
||||||
|
|||||||
@@ -264,9 +264,8 @@ document is both (previous section), the shim uses an **indirection**:
|
|||||||
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
named doc-shim through its barrier (`ensureRepoOpen`) → read the account
|
||||||
AUTHORITATIVELY. First login (no pointer): `doc_create` the doc-shim, publish the
|
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
|
pointer, done. A **pointer fork** (two devices each writing a pointer before either
|
||||||
synced) is **benign**: reconcile to the lexicographically-smallest doc-shim NURI
|
synced) is reconciled to the lexicographically-smallest doc-shim NURI
|
||||||
(content-addressed, so every device converges), and no account data is lost — a
|
(content-addressed, so every device converges on the same doc-shim).
|
||||||
non-canonical doc-shim's records are migrated forward on the next resolve.
|
|
||||||
|
|
||||||
**The account-level retry is GONE.** Before this indirection the shim lived directly
|
**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;
|
in the store-root graph, so an account read had no barrier and a cold 0 was ambiguous;
|
||||||
@@ -279,12 +278,12 @@ re-read of the **pointer** itself (`pointerGuard`, one write-once triple): it ca
|
|||||||
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
NEVER re-provision or fork an account — at worst it takes a couple extra reads to see
|
||||||
a pointer that is still landing.
|
a pointer that is still landing.
|
||||||
|
|
||||||
**Migration (no existing account lost).** A wallet whose accounts were written under
|
**No legacy migration.** A wallet written under the OLD scheme (accounts directly in
|
||||||
the OLD scheme has its `AccountRecord`s in the store-root graph, not a doc-shim. On
|
the store-root graph, no pointer) is NOT recovered: opening it under the current scheme
|
||||||
resolution, `migrateLegacyRecords` reads that old location best-effort and RE-INSERTS
|
simply provisions a fresh doc-shim, and the pre-indirection store-root records are
|
||||||
each legacy record into the doc-shim (copy, not move — the store-root copy is left
|
ignored. This is deliberate — the only such wallets are dev data — so the resolution
|
||||||
inert), BEFORE any "account absent" conclusion. So a legacy account is READ (and made
|
path carries no legacy-migration step; it always reads the account authoritatively from
|
||||||
barrier-authoritative going forward), never re-provisioned into a fork.
|
the doc-shim.
|
||||||
|
|
||||||
### The union is read-only — writes must target one document
|
### The union is read-only — writes must target one document
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -107,7 +107,8 @@ public/protected/private stores — on top of one shared wallet.
|
|||||||
through its barrier, and reads the account authoritatively — so a fresh reconnecting
|
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).
|
session never mistakes sync-lag for "account absent" (which would provision a FORK).
|
||||||
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
Full rationale — including why the old account-level retry (`provisionRetry`) is
|
||||||
removed and how legacy store-root records are migrated forward — is in
|
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
|
[`nextgraph-current-state.md`](./nextgraph-current-state.md) §§ *Findable vs
|
||||||
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
subscribable* / *The pointer → doc-shim indirection*. This map is the
|
||||||
account→document trust root, which is why every untrusted value that reaches its
|
account→document trust root, which is why every untrusted value that reaches its
|
||||||
|
|||||||
@@ -61,12 +61,27 @@ function activeIdentity(): string {
|
|||||||
return getCurrentUser() ?? "(none)";
|
return getCurrentUser() ?? "(none)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* `[polyfill] [<identity>] READ <shortNuri> (<label>)` — optionally with `<extra>`
|
||||||
* (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity that
|
* appended (e.g. ` → 3 rows`, a strong signal a doc rendered data under an identity
|
||||||
* should see nothing).
|
* that should see nothing). The `[polyfill]` prefix 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 +91,6 @@ export function logAccess(
|
|||||||
): void {
|
): void {
|
||||||
if (!enabled()) return;
|
if (!enabled()) return;
|
||||||
console.log(
|
console.log(
|
||||||
"[" + activeIdentity() + "] " + op + " " + nuri + " (" + label + ")" + (extra ?? ""),
|
"[polyfill] [" + activeIdentity() + "] " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,8 @@
|
|||||||
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
|
* BARRIER and reads the account AUTHORITATIVELY (0 = genuinely absent).
|
||||||
*
|
*
|
||||||
* A pointer FORK (two devices writing the pointer before either synced) is
|
* A pointer FORK (two devices writing the pointer before either synced) is
|
||||||
* BENIGN: the pointer is reconciled to a canonical doc-shim (lexicographically-
|
* reconciled to a canonical doc-shim (lexicographically-smallest NURI) so every
|
||||||
* smallest NURI) and destroys no account data — worst case a doc-shim not chosen
|
* device converges on the SAME doc-shim. This is why the OLD account-level retry
|
||||||
* is migrated forward on next resolve. This is why the OLD account-level retry
|
|
||||||
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
|
* (`resolveAccountReliably` / `provisionRetry`) is GONE: the account read is now
|
||||||
* barrier-authoritative, so it never needs to be retried to distinguish sync-lag
|
* 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
|
* from absence. A micro-guard remains ONLY on the pointer read (one write-once
|
||||||
@@ -201,9 +200,6 @@ export function resetRegistryCache(): void {
|
|||||||
accountCache.clear();
|
accountCache.clear();
|
||||||
shimDocNuri = null;
|
shimDocNuri = null;
|
||||||
shimDocInFlight = null;
|
shimDocInFlight = null;
|
||||||
// The per-session migration guard is tied to the resolved doc-shim, which is
|
|
||||||
// dropped here — so a switched wallet (or a test) re-runs the legacy scan.
|
|
||||||
migratedInto.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- SPARQL result helpers ------------------------------------------------
|
// --- SPARQL result helpers ------------------------------------------------
|
||||||
@@ -299,8 +295,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
|||||||
* If MULTIPLE pointers exist (a pointer fork: two devices each wrote a pointer to
|
* 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
|
* their own freshly-created doc-shim before either synced), reconcile to the
|
||||||
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
|
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
|
||||||
* stable, so every device converges on the SAME doc-shim. A pointer fork is benign:
|
* stable, so every device converges on the SAME doc-shim.
|
||||||
* no account data is lost (migration folds any non-canonical doc-shim forward).
|
|
||||||
*/
|
*/
|
||||||
async function resolvePointer(): Promise<Nuri> {
|
async function resolvePointer(): Promise<Nuri> {
|
||||||
const s = await session();
|
const s = await session();
|
||||||
@@ -379,15 +374,12 @@ async function createDoc(): Promise<Nuri> {
|
|||||||
* Steps (cached; runs at most once per session, concurrent callers share one):
|
* 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
|
* 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
|
* the doc-shim; open it through its first-`State` BARRIER so subsequent account
|
||||||
* reads are AUTHORITATIVE, then MIGRATE any legacy store-root records into it.
|
* reads are AUTHORITATIVE.
|
||||||
* 2. No pointer → FIRST login (or a wallet from before the indirection):
|
* 2. No pointer → FIRST login:
|
||||||
* a. create a fresh doc-shim (`doc_create`), which bootstraps the repo into the
|
* 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;
|
* session (`self.repos`) — so it is already open/synced in-session;
|
||||||
* b. publish the pointer (store-root → the new doc-shim), once;
|
* b. publish the pointer (store-root → the new doc-shim), once;
|
||||||
* c. open it (barrier — trivially satisfied for a just-created in-session repo)
|
* c. open it (barrier — trivially satisfied for a just-created in-session repo).
|
||||||
* and MIGRATE any legacy AccountRecords found in the store-root graph into it
|
|
||||||
* (see migrateLegacyRecords) — this is what guarantees NO existing account is
|
|
||||||
* lost when a pre-indirection wallet is opened for the first time.
|
|
||||||
* The BARRIER matters on RECONNECT (step 1, reading an EXISTING remote doc-shim);
|
* 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.
|
* on first-login creation it is a no-op, so the pointer is published first.
|
||||||
*/
|
*/
|
||||||
@@ -401,20 +393,15 @@ async function resolveShimDoc(): Promise<Nuri> {
|
|||||||
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
|
// 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.
|
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
|
||||||
await ensureRepoOpen(existing);
|
await ensureRepoOpen(existing);
|
||||||
// A pointer that predates a migration still points at a doc-shim, but the LEGACY
|
|
||||||
// store-root records (from before the indirection) may not have been folded in.
|
|
||||||
// Migrate best-effort (idempotent) so no legacy account is ever stranded.
|
|
||||||
await migrateLegacyRecords(existing);
|
|
||||||
shimDocNuri = existing;
|
shimDocNuri = existing;
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
|
// FIRST login (no pointer): create the doc-shim (bootstrapped in-session), publish
|
||||||
// the pointer, then open (no-op barrier for a just-created repo) + migrate legacy.
|
// the pointer, then open (no-op barrier for a just-created repo).
|
||||||
const doc = await createDoc();
|
const doc = await createDoc();
|
||||||
await writePointer(doc);
|
await writePointer(doc);
|
||||||
await ensureRepoOpen(doc);
|
await ensureRepoOpen(doc);
|
||||||
await migrateLegacyRecords(doc);
|
|
||||||
shimDocNuri = doc;
|
shimDocNuri = doc;
|
||||||
return doc;
|
return doc;
|
||||||
})();
|
})();
|
||||||
@@ -427,87 +414,6 @@ async function resolveShimDoc(): Promise<Nuri> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- migration (legacy store-root records → the doc-shim) ------------------
|
|
||||||
|
|
||||||
// Guard so the (best-effort, O(all-accounts)) legacy scan runs at most once per
|
|
||||||
// session per target doc-shim. Cleared with the rest via resetRegistryCache.
|
|
||||||
const migratedInto = new Set<Nuri>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MIGRATION — fold any AccountRecords that live at the OLD location (the store-root
|
|
||||||
* graph, where the pre-indirection shim wrote them) into the new doc-shim.
|
|
||||||
*
|
|
||||||
* ── Why this exists (do NOT lose an existing account) ──────────────────────
|
|
||||||
* Before the indirection, `ensureAccount` wrote `<subj> a Account ; id ; docPublic ;
|
|
||||||
* docProtected ; docPrivate` directly into the store-root graph. A wallet created
|
|
||||||
* under that scheme has its accounts there, not in a doc-shim. When such a wallet is
|
|
||||||
* first opened under the new scheme, resolveShimDoc creates/opens the doc-shim; this
|
|
||||||
* function reads the store-root graph best-effort and RE-INSERTS every legacy record
|
|
||||||
* into the doc-shim, so barrier-authoritative resolveAccount finds it — instead of
|
|
||||||
* seeing 0 in the (fresh) doc-shim and provisioning a NEW forked account.
|
|
||||||
*
|
|
||||||
* ── Behaviour (precise) ────────────────────────────────────────────────────
|
|
||||||
* - Best-effort: a store-root read failure (RepoNotFound on a truly fresh wallet)
|
|
||||||
* is swallowed — there is simply nothing to migrate.
|
|
||||||
* - Copy, don't move: legacy triples are LEFT in the store-root (never deleted), so
|
|
||||||
* the migration is safe to re-run and an aborted migration never loses data. The
|
|
||||||
* doc-shim becomes the authoritative read location; the store-root copy is inert.
|
|
||||||
* - Idempotent: re-inserting the same `<subj> ... docPublic <X>` triples is a no-op
|
|
||||||
* (RDF set semantics); and `migratedInto` skips the scan after the first run per
|
|
||||||
* session. Re-running after new legacy writes (there should be none post-migration)
|
|
||||||
* would simply re-copy them.
|
|
||||||
* - Runs once per session before the first authoritative account read, so a legacy
|
|
||||||
* account resolves on the very first resolveAccount after reconnect.
|
|
||||||
*/
|
|
||||||
async function migrateLegacyRecords(doc: Nuri): Promise<void> {
|
|
||||||
if (migratedInto.has(doc)) return;
|
|
||||||
migratedInto.add(doc);
|
|
||||||
|
|
||||||
const s = await session();
|
|
||||||
const root = await rootNuri();
|
|
||||||
// Read every legacy account record from the OLD store-root location.
|
|
||||||
const query = `
|
|
||||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
|
||||||
GRAPH <${assertNuri(root)}> {
|
|
||||||
?acc a <${P.type}> ;
|
|
||||||
<${P.id}> ?id ;
|
|
||||||
<${P.docPublic}> ?docPublic ;
|
|
||||||
<${P.docProtected}> ?docProtected ;
|
|
||||||
<${P.docPrivate}> ?docPrivate .
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
let rows: Array<Record<string, { value: string }>> = [];
|
|
||||||
try {
|
|
||||||
await ensureRepoOpen(root);
|
|
||||||
const result = await sparqlQuery(s.sessionId, query, undefined, root, "migrateLegacyRead");
|
|
||||||
rows = readBindings(result);
|
|
||||||
} catch (error) {
|
|
||||||
// No legacy repo / nothing to read → nothing to migrate. Not an error.
|
|
||||||
console.error("[storeRegistry] migrateLegacyRecords read (best-effort) failed:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rows.length === 0) return;
|
|
||||||
|
|
||||||
// Group by account subject, pick the canonical doc per scope (fold any legacy fork
|
|
||||||
// residue the SAME content-addressed way as resolveAccount), and re-insert into the
|
|
||||||
// doc-shim. Grouping needs the subject; the SELECT above doesn't bind it, so we key
|
|
||||||
// on the account id literal (stable per account) instead.
|
|
||||||
const byId = new Map<string, Array<Record<string, { value: string }>>>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const id = bindingValue(row, "id");
|
|
||||||
if (!id) continue;
|
|
||||||
const bucket = byId.get(id) ?? [];
|
|
||||||
bucket.push(row);
|
|
||||||
byId.set(id, bucket);
|
|
||||||
}
|
|
||||||
await ensureRepoOpen(doc);
|
|
||||||
for (const [id, group] of byId) {
|
|
||||||
const record = recordFromRows(group, id);
|
|
||||||
if (!record.docPublic && !record.docProtected && !record.docPrivate) continue;
|
|
||||||
await writeRecord(doc, record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- shim load / account bootstrap ----------------------------------------
|
// --- shim load / account bootstrap ----------------------------------------
|
||||||
|
|
||||||
/** Load all accounts from the shim (the doc-shim) into the cache. */
|
/** Load all accounts from the shim (the doc-shim) into the cache. */
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* (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 `[polyfill] [<identity>] READ/WRITE <shortNuri> (<label>)` plus
|
||||||
* on READs.
|
* 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 +18,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,
|
||||||
@@ -159,9 +160,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
|
expect(lines[0]).toMatch(/^\[polyfill\] /); // SDK-layer access-log prefix
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/\[alice\]/);
|
||||||
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 triple-rows/); // triple-count from the 1-row fake result
|
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
|
||||||
});
|
});
|
||||||
@@ -176,9 +179,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(/^\[polyfill\] /);
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/\[alice\]/);
|
||||||
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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,10 +196,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
|||||||
restore();
|
restore();
|
||||||
}
|
}
|
||||||
expect(lines.length).toBe(1);
|
expect(lines.length).toBe(1);
|
||||||
|
expect(lines[0]).toMatch(/^\[polyfill\] /);
|
||||||
expect(lines[0]).toMatch(/\[alice\]/);
|
expect(lines[0]).toMatch(/\[alice\]/);
|
||||||
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", () => {
|
||||||
@@ -218,9 +223,10 @@ 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(/^\[polyfill\] /);
|
||||||
expect(lines[0]).toMatch(/\[bob\]/);
|
expect(lines[0]).toMatch(/\[bob\]/);
|
||||||
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 () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
|
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
|
||||||
* polyfill-era shim (src/store-registry.ts), redesigned around the
|
* polyfill-era shim (src/store-registry.ts), redesigned around the
|
||||||
* pointer → doc-shim indirection. Three groups, one file:
|
* pointer → doc-shim indirection. Two groups, one file:
|
||||||
*
|
*
|
||||||
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
|
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
|
||||||
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
|
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
|
||||||
@@ -17,11 +17,6 @@
|
|||||||
* authoritative, so a genuinely-present account is found on the first read
|
* authoritative, so a genuinely-present account is found on the first read
|
||||||
* and never re-provisioned (no fork). This replaced the deleted
|
* and never re-provisioned (no fork). This replaced the deleted
|
||||||
* `resolveAccountReliably` / `provisionRetry` account retry.
|
* `resolveAccountReliably` / `provisionRetry` account retry.
|
||||||
*
|
|
||||||
* (3) MIGRATION — a wallet whose accounts were written at the OLD location (the
|
|
||||||
* store-root graph, pre-indirection) must have those records MIGRATED into
|
|
||||||
* the doc-shim at resolution, so a legacy account resolves (is READ, not
|
|
||||||
* re-provisioned) after reconnect. No existing account is lost.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||||
@@ -56,8 +51,7 @@ const ROOT = "did:ng:PRIV-AF";
|
|||||||
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
|
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
|
||||||
// graph (keyed by GRAPH <ROOT>).
|
// graph (keyed by GRAPH <ROOT>).
|
||||||
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
|
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
|
||||||
// anchor arg = the doc-shim NURI). A LEGACY record may ALSO be seeded directly
|
// anchor arg = the doc-shim NURI).
|
||||||
// in the store-root graph (the pre-indirection location) to exercise migration.
|
|
||||||
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
|
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
|
||||||
// (the barrier).
|
// (the barrier).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -107,9 +101,9 @@ function makeSparqlUpdate(quads: Quad[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim, or
|
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
|
||||||
* the store-root for the migration read), grouped per subject. A subject with
|
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
|
||||||
* DUPLICATE scope docs yields a cross-product of bindings — a corrupted shim. */
|
* of bindings — a corrupted shim. */
|
||||||
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
|
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
|
||||||
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
|
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
|
||||||
for (const q of quads) {
|
for (const q of quads) {
|
||||||
@@ -147,9 +141,8 @@ function makeFakeNg() {
|
|||||||
const quads: Quad[] = [];
|
const quads: Quad[] = [];
|
||||||
let docCounter = 0;
|
let docCounter = 0;
|
||||||
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
|
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
|
||||||
// to the doc-shim (a did:ng:o: NURI); the one-time legacy MIGRATION scan is anchored
|
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
|
||||||
// to the store-root (ROOT). Counting per-anchor lets a test assert "exactly one
|
// one doc-shim account read" (no account RETRY).
|
||||||
// doc-shim account read" (no account RETRY) separately from the migration scan.
|
|
||||||
const accountReadsByAnchor = new Map<string, number>();
|
const accountReadsByAnchor = new Map<string, number>();
|
||||||
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
|
||||||
const sparql_update = makeSparqlUpdate(quads);
|
const sparql_update = makeSparqlUpdate(quads);
|
||||||
@@ -163,8 +156,7 @@ function makeFakeNg() {
|
|||||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||||
return { results: { bindings } };
|
return { results: { bindings } };
|
||||||
}
|
}
|
||||||
// Account SELECT — anchored either to the doc-shim (authoritative read) or, for
|
// Account SELECT — anchored to the doc-shim (the authoritative read).
|
||||||
// the migration scan, to the store-root (legacy location). Same shape either way.
|
|
||||||
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
if (query.includes("<urn:ng-eventually:shim:id>")) {
|
||||||
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
|
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
|
||||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||||
@@ -185,7 +177,7 @@ function makeFakeNg() {
|
|||||||
_quads: quads,
|
_quads: quads,
|
||||||
// Total account reads across all anchors.
|
// Total account reads across all anchors.
|
||||||
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
|
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
|
||||||
// Account reads anchored to a did:ng:o: doc-shim (excludes the store-root migration scan).
|
// Account reads anchored to a did:ng:o: doc-shim.
|
||||||
getDocShimAccountReads: () =>
|
getDocShimAccountReads: () =>
|
||||||
[...accountReadsByAnchor.entries()]
|
[...accountReadsByAnchor.entries()]
|
||||||
.filter(([g]) => g.startsWith("did:ng:o:"))
|
.filter(([g]) => g.startsWith("did:ng:o:"))
|
||||||
@@ -292,8 +284,7 @@ describe("reconnect resolves the SAME account through the doc-shim barrier (no f
|
|||||||
expect(resolved.docProtected).toBe(orig.docProtected);
|
expect(resolved.docProtected).toBe(orig.docProtected);
|
||||||
expect(resolved.docPrivate).toBe(orig.docPrivate);
|
expect(resolved.docPrivate).toBe(orig.docPrivate);
|
||||||
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
|
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
|
||||||
// Barrier-authoritative: found on the FIRST doc-shim read (the store-root migration
|
// Barrier-authoritative: found on the FIRST doc-shim read.
|
||||||
// scan, which finds nothing to migrate here, is separate).
|
|
||||||
expect(reconnect.getDocShimAccountReads()).toBe(1);
|
expect(reconnect.getDocShimAccountReads()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,62 +327,3 @@ describe("reconnect resolves the SAME account through the doc-shim barrier (no f
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// (3) Migration — legacy store-root records folded into the doc-shim
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
describe("migration: legacy store-root records are read (not re-provisioned) after reconnect", () => {
|
|
||||||
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
|
|
||||||
|
|
||||||
it("(3a) a pre-indirection account (records in the store-root, no pointer) is migrated into a new doc-shim and resolves — no fork", async () => {
|
|
||||||
const fakeNg = makeFakeNg();
|
|
||||||
inject(fakeNg);
|
|
||||||
// Seed a LEGACY account directly in the store-root graph (old location), NO pointer.
|
|
||||||
const subj = "urn:ng-eventually:shim:account:legacyuser";
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:id", o: "legacyuser" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPublic", o: "did:ng:o:legacy-pub" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:legacy-prot" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:legacy-priv" });
|
|
||||||
|
|
||||||
const rec = await ensureAccount("legacyuser");
|
|
||||||
|
|
||||||
// The legacy account was READ (migrated), NOT re-provisioned: original NURIs come
|
|
||||||
// back, and NO scope docs were created (only the fresh doc-shim: 1 doc_create).
|
|
||||||
expect(rec.docPublic).toBe("did:ng:o:legacy-pub");
|
|
||||||
expect(rec.docProtected).toBe("did:ng:o:legacy-prot");
|
|
||||||
expect(rec.docPrivate).toBe("did:ng:o:legacy-priv");
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(1); // only the new doc-shim
|
|
||||||
|
|
||||||
// A pointer was written, and the record now also lives in the doc-shim.
|
|
||||||
const pointerRow = fakeNg._quads.find(
|
|
||||||
(q) => q.g === ROOT && q.p === "urn:ng-eventually:shim:shimDoc",
|
|
||||||
);
|
|
||||||
expect(pointerRow?.o).toMatch(/^did:ng:o:doc/);
|
|
||||||
const inDocShim = fakeNg._quads.some(
|
|
||||||
(q) => q.g === pointerRow!.o && q.p === "urn:ng-eventually:shim:docPublic" && q.o === "did:ng:o:legacy-pub",
|
|
||||||
);
|
|
||||||
expect(inDocShim).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(3b) a pointer that predates migration still folds legacy store-root records into its doc-shim", async () => {
|
|
||||||
const fakeNg = makeFakeNg();
|
|
||||||
inject(fakeNg);
|
|
||||||
// A doc-shim + pointer already exist (empty doc-shim), records still only in the root.
|
|
||||||
const docShim = "did:ng:o:existing-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:oldtimer";
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:id", o: "oldtimer" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPublic", o: "did:ng:o:old-pub" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:old-prot" });
|
|
||||||
fakeNg._quads.push({ g: ROOT, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:old-priv" });
|
|
||||||
|
|
||||||
const rec = await ensureAccount("oldtimer");
|
|
||||||
|
|
||||||
expect(rec.docPublic).toBe("did:ng:o:old-pub");
|
|
||||||
expect(fakeNg.doc_create).toHaveBeenCalledTimes(0); // doc-shim already existed
|
|
||||||
const inDocShim = fakeNg._quads.some(
|
|
||||||
(q) => q.g === docShim && q.p === "urn:ng-eventually:shim:docPublic" && q.o === "did:ng:o:old-pub",
|
|
||||||
);
|
|
||||||
expect(inDocShim).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user