diff --git a/apps/extension/scripts/extension-manifest.mjs b/apps/extension/scripts/extension-manifest.mjs index 43dc930..befd94d 100644 --- a/apps/extension/scripts/extension-manifest.mjs +++ b/apps/extension/scripts/extension-manifest.mjs @@ -14,6 +14,8 @@ export function extensionEnvironment(environment = process.env) { connectUrl: deployment.connectUrl, loopbackUrl: deployment.loopbackUrl, readerOrigin: deployment.origin, + // Opt-in mdbase-next SDK backend (MDBASE_SDK=next); Connect stays the default. + sdk: environment.MDBASE_SDK === "next" ? "next" : "connect", }; } diff --git a/apps/extension/src/connect-session.ts b/apps/extension/src/connect-session.ts index b7300c2..de241da 100644 --- a/apps/extension/src/connect-session.ts +++ b/apps/extension/src/connect-session.ts @@ -2,7 +2,9 @@ import { ReaderPortableApplicationSession, type MdbaseAppManifest, type ReaderConnectSnapshot, + type ReaderPortableSession, } from "@mdbase-reader/connect"; +import { ReaderNextApplicationSession, readerSdkBackend } from "@mdbase-reader/connect/next"; import { chromeStorageMirror } from "./chrome-storage.js"; import { lastCollectionKey } from "./collection-memory.js"; @@ -14,21 +16,29 @@ import type { KeyValueStorage } from "@mdbase-reader/platform"; export { rememberCollection, rememberedCollection } from "./collection-memory.js"; export interface ExtensionSession { - readonly session: ReaderPortableApplicationSession; + readonly session: ReaderPortableSession; /** Reader's mutation journal, kept beside the grants so any extension context can recover it. */ readonly journalStorage: KeyValueStorage; } export async function createExtensionSession(): Promise { const storage = await chromeStorageMirror(); - const session = new ReaderPortableApplicationSession({ - serverUrl: environment.connectUrl, - loopbackUrl: environment.loopbackUrl, - manifest: manifest as MdbaseAppManifest, - storage, - // Interactive capture should not wait the SDK's ten-minute file-index default. - timeouts: { watchStartMs: 60_000, fileIndexMs: 30_000, uploadMs: 120_000 }, - }); + const session: ReaderPortableSession = + readerSdkBackend(null, environment.sdk) === "next" + ? // The client key is a non-extractable WebCrypto key kept in IndexedDB. + new ReaderNextApplicationSession({ + serverUrl: environment.connectUrl, + app: { name: manifest.id, version: chrome.runtime.getManifest().version }, + storage, + }) + : new ReaderPortableApplicationSession({ + serverUrl: environment.connectUrl, + loopbackUrl: environment.loopbackUrl, + manifest: manifest as MdbaseAppManifest, + storage, + // Interactive capture should not wait the SDK's ten-minute file-index default. + timeouts: { watchStartMs: 60_000, fileIndexMs: 30_000, uploadMs: 120_000 }, + }); return { session, journalStorage: { @@ -41,7 +51,7 @@ export async function createExtensionSession(): Promise { /** Selects the collection chosen last time, if it is still authorized. */ export async function restoreCollection( - session: ReaderPortableApplicationSession, + session: ReaderPortableSession, ): Promise { const snapshot = session.getSnapshot(); if (snapshot.status !== "unselected") { diff --git a/apps/extension/src/environment.ts b/apps/extension/src/environment.ts index 07fe5ee..0deaba9 100644 --- a/apps/extension/src/environment.ts +++ b/apps/extension/src/environment.ts @@ -5,6 +5,8 @@ export interface ExtensionEnvironment { readonly connectUrl: string; readonly loopbackUrl: string; readonly readerOrigin: string; + /** `next` opts into the mdbase-next SDK backend (MDBASE_SDK=next at build time). */ + readonly sdk?: "connect" | "next"; } declare const __READER_EXTENSION_ENVIRONMENT__: ExtensionEnvironment; diff --git a/apps/extension/src/page-status-connect.ts b/apps/extension/src/page-status-connect.ts index e411099..4fe7a97 100644 --- a/apps/extension/src/page-status-connect.ts +++ b/apps/extension/src/page-status-connect.ts @@ -6,10 +6,7 @@ import { } from "./connect-session.js"; import { annotationQuotes, drawPageQuotes, type PageQuote } from "./page-annotations.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; /** * The part of page status that needs Connect. The service worker loads it (with the @@ -29,7 +26,7 @@ export interface PageStatusConnect { drawPageQuotes(tabId: number, quotes: readonly PageQuote[], url: string): Promise; } -let session: Promise | null = null; +let session: Promise | null = null; /** One Connect session per service-worker lifetime, sharing the panel's stored grants. */ async function selectedCollection(): Promise { diff --git a/apps/extension/src/save-capture.ts b/apps/extension/src/save-capture.ts index 2b69ff4..de79344 100644 --- a/apps/extension/src/save-capture.ts +++ b/apps/extension/src/save-capture.ts @@ -36,10 +36,7 @@ import { pageText } from "./page-annotations.js"; import { rememberSavedUrls } from "./saved-url-index.js"; import type { PageCapture, PdfCapture, SelectedWebCapture } from "./page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; export const highlightColors = ["yellow", "green", "blue", "pink", "purple"] as const; export type HighlightColor = (typeof highlightColors)[number]; @@ -61,7 +58,7 @@ export interface SavedCapture { readonly notices: readonly string[]; } export interface SaveCaptureInput { - readonly session: ReaderPortableApplicationSession; + readonly session: ReaderPortableSession; readonly collection: ReaderConnectedCollection; readonly capture: PageCapture; readonly draft: CaptureDraft; diff --git a/apps/extension/src/testing/save-capture-fixture.ts b/apps/extension/src/testing/save-capture-fixture.ts index 0432bde..3db3efe 100644 --- a/apps/extension/src/testing/save-capture-fixture.ts +++ b/apps/extension/src/testing/save-capture-fixture.ts @@ -18,10 +18,7 @@ import { } from "../save-capture.js"; import type { PageCapture, SelectedWebCapture } from "../page-capture.js"; -import type { - ReaderConnectedCollection, - ReaderPortableApplicationSession, -} from "@mdbase-reader/connect"; +import type { ReaderConnectedCollection, ReaderPortableSession } from "@mdbase-reader/connect"; export const capture: SelectedWebCapture = { kind: "html", @@ -119,7 +116,7 @@ export function fixture() { } as unknown as ReaderConnectedCollection; const session = { recoverPendingMutations: vi.fn(() => Promise.resolve([])), - } as unknown as ReaderPortableApplicationSession; + } as unknown as ReaderPortableSession; const onSource = vi.fn(); const journal = new Map(); const storage = { diff --git a/apps/extension/src/use-connect.ts b/apps/extension/src/use-connect.ts index c5fd795..ef51068 100644 --- a/apps/extension/src/use-connect.ts +++ b/apps/extension/src/use-connect.ts @@ -2,7 +2,7 @@ import { connectProblemMessage, type ReaderConnectSnapshot, type ReaderDirectAccessController, - type ReaderPortableApplicationSession, + type ReaderPortableSession, } from "@mdbase-reader/connect"; import { useCallback, useEffect, useState } from "react"; @@ -23,7 +23,7 @@ export interface ConnectLink { readonly deviceCode: string | null; readonly directAccess: ReaderDirectAccessController | null; /** Creates the session (grants load from extension storage) and restores the last collection. */ - readonly open: () => Promise; + readonly open: () => Promise; readonly connect: (choose?: boolean) => Promise; readonly retry: () => Promise; readonly applySetup: () => Promise; @@ -48,7 +48,7 @@ export function useConnect(lock: ActionLock): ConnectLink { }; }, [extension]); - const open = useCallback(async (): Promise => { + const open = useCallback(async (): Promise => { const created = await createExtensionSession(); setExtension(created); const problem = connectProblemMessage(await created.session.start()); diff --git a/apps/reader/src/connect.ts b/apps/reader/src/connect.ts index bcf56e0..3affe70 100644 --- a/apps/reader/src/connect.ts +++ b/apps/reader/src/connect.ts @@ -2,7 +2,9 @@ import { ReaderApplicationSession, manifestForApplicationUrl, type ReaderApplicationSessionOptions, + type ReaderSession, } from "@mdbase-reader/connect"; +import { ReaderNextApplicationSession, readerSdkBackend } from "@mdbase-reader/connect/next"; import bundledManifest from "./generated/mdbase-app.json"; @@ -17,17 +19,38 @@ const manifest = isLoopbackApplication(location) ? manifestForApplicationUrl(declaredManifest, applicationUrl, callbackUrl.href) : declaredManifest; -export const readerSession = new ReaderApplicationSession({ - serverUrl: - serverParameter ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev", - loopbackUrl: import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485", - manifest, - redirectUri: callbackUrl.href, - fallbackPath: import.meta.env.BASE_URL, - // Application startup includes collection setup verification. A remote connector - // may need more than the SDK's interactive 10-second discovery default. - timeouts: { watchStartMs: 60_000 }, -}); +const serverUrl = + serverParameter ?? import.meta.env.VITE_MDBASE_CONNECT_URL ?? "https://connect.mdbase.dev"; +// Opt-in mdbase-next backend: `?sdk=next` or VITE_MDBASE_SDK=next. Connect stays the default. +const sdkBackend = readerSdkBackend( + new URL(location.href).searchParams.get("sdk"), + import.meta.env.VITE_MDBASE_SDK, +); + +export const readerSession: ReaderSession = + sdkBackend === "next" + ? new ReaderNextApplicationSession({ + serverUrl, + app: { + name: declaredManifest.id, + version: import.meta.env.VITE_MDBASE_READER_BUILD_ID ?? "development", + }, + storage: localStorage, + }) + : connectSession(); + +function connectSession(): ReaderApplicationSession { + return new ReaderApplicationSession({ + serverUrl, + loopbackUrl: import.meta.env.VITE_MDBASE_CONNECT_LOOPBACK_URL ?? "http://127.0.0.1:28485", + manifest, + redirectUri: callbackUrl.href, + fallbackPath: import.meta.env.BASE_URL, + // Application startup includes collection setup verification. A remote connector + // may need more than the SDK's interactive 10-second discovery default. + timeouts: { watchStartMs: 60_000 }, + }); +} function isLoopbackApplication(current: Location): boolean { return ( diff --git a/apps/reader/src/env.d.ts b/apps/reader/src/env.d.ts index a8633e1..c6c8aee 100644 --- a/apps/reader/src/env.d.ts +++ b/apps/reader/src/env.d.ts @@ -6,6 +6,8 @@ interface ImportMetaEnv { readonly VITE_MDBASE_FEEDBACK_TURNSTILE_SITE_KEY?: string; readonly VITE_MDBASE_CONNECT_URL?: string; readonly VITE_MDBASE_CONNECT_LOOPBACK_URL?: string; + /** `next` opts into the mdbase-next SDK backend; Connect is the default. */ + readonly VITE_MDBASE_SDK?: string; readonly VITE_MDBASE_READER_BUILD_ID?: string; readonly VITE_MDBASE_EDITOR_URL?: string; readonly VITE_MDBASE_READER_URL?: string; diff --git a/apps/reader/src/import-controller.ts b/apps/reader/src/import-controller.ts index e732ca4..da13007 100644 --- a/apps/reader/src/import-controller.ts +++ b/apps/reader/src/import-controller.ts @@ -12,7 +12,7 @@ import { import { markFraction, withMarkProgress } from "./mark-activity.js"; -import type { ReaderApplicationSession } from "@mdbase-reader/connect"; +import type { ReaderSession } from "@mdbase-reader/connect"; export interface ImportState { plan: MigrationPlan | null; message: string; @@ -37,7 +37,7 @@ export class ImportController { private listeners = new Set<() => void>(); private active: AbortController | null = null; private client: ReadwiseClient | null = null; - constructor(private session: ReaderApplicationSession) {} + constructor(private session: ReaderSession) {} getSnapshot = (): ImportState => this.state; subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); diff --git a/eslint.config.js b/eslint.config.js index eb9ae9d..319931c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -110,6 +110,8 @@ export default tseslint.config( "electron", "@capacitor/*", "@mdbase-dev/connect", + "@mdbase-dev/sdk", + "@mdbase-dev/sdk/*", "@embedpdf/*", "@readium/*", "@codemirror/*", diff --git a/packages/connect/package.json b/packages/connect/package.json index f8f79e4..3a275cd 100644 --- a/packages/connect/package.json +++ b/packages/connect/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./next": "./src/next/index.ts" }, "scripts": { "test": "vitest run --passWithNoTests", @@ -12,6 +13,7 @@ }, "dependencies": { "@mdbase-dev/connect": "0.1.0-beta.125", + "@mdbase-dev/sdk": "file:vendor/mdbase-dev-sdk-c573c96.tgz", "@mdbase-reader/core": "workspace:*", "@mdbase-reader/migration": "workspace:*", "yaml": "^2.9.0" diff --git a/packages/connect/src/application-session.ts b/packages/connect/src/application-session.ts index afe2b41..81bac3b 100644 --- a/packages/connect/src/application-session.ts +++ b/packages/connect/src/application-session.ts @@ -39,6 +39,8 @@ import { } from "./repositories.js"; import { connectSourceImportRepository } from "./source-imports.js"; +import type { MigrationTarget } from "@mdbase-reader/migration"; + export type ReaderConnectSnapshot = MdbaseApplicationSessionSnapshot; export interface ReaderDirectAccessSnapshot { @@ -60,7 +62,7 @@ export interface ReaderConnectedCollection { readonly collectionName: string; readonly sources: SourceRepository; readonly sourceImports: SourceImportRepository; - readonly migration: ConnectMigrationTarget; + readonly migration: MigrationTarget; readonly annotations: AnnotationRepository; readonly annotationAssets: AnnotationAssetRepository; readonly documents: DocumentRepository; @@ -80,7 +82,19 @@ export interface ReaderApplicationSessionOptions { readonly timeouts?: MdbaseConnectTimeouts; } -export class ReaderApplicationSession { +/** What Reader's web app needs from a session, whichever SDK backs it. */ +export interface ReaderSession { + start(): Promise>; + destroy(): void; + getSnapshot(): ReaderConnectSnapshot; + subscribe(listener: () => void): () => void; + select(selectedCollectionId: string): ConnectOutcome; + authorize(target: "choose" | "selected", popup?: boolean): Promise>; + applyCollectionSetup(): Promise>; + connectedCollection(expectedCollectionId?: string): ReaderConnectedCollection | null; +} + +export class ReaderApplicationSession implements ReaderSession { readonly #session; public constructor(options: ReaderApplicationSessionOptions) { diff --git a/packages/connect/src/next/backend.ts b/packages/connect/src/next/backend.ts new file mode 100644 index 0000000..a652f4e --- /dev/null +++ b/packages/connect/src/next/backend.ts @@ -0,0 +1,15 @@ +// Which SDK backs Reader's session. The current Connect SDK stays the default until cutover. + +export type ReaderSdkBackend = "connect" | "next"; + +/** + * Picks the backend the way Reader picks its server: a `?sdk=next` URL flag first, + * then `VITE_MDBASE_SDK=next` at build time. Anything else is the Connect SDK. + */ +export function readerSdkBackend( + urlFlag: string | null | undefined, + buildSetting?: string, +): ReaderSdkBackend { + const choice = (urlFlag ?? buildSetting ?? "").trim().toLowerCase(); + return choice === "next" ? "next" : "connect"; +} diff --git a/packages/connect/src/next/client.test.ts b/packages/connect/src/next/client.test.ts new file mode 100644 index 0000000..17abd38 --- /dev/null +++ b/packages/connect/src/next/client.test.ts @@ -0,0 +1,269 @@ +import { connect, mdbaseError, toPlain, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica, type MemoryRecord } from "@mdbase-dev/sdk/testing"; +import { collectionId, sourceId } from "@mdbase-reader/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ConnectContentSearchRepository } from "../content-search-repository.js"; +import { sourceContract } from "../contracts.js"; +import { ConnectRepositoryError, outcomeValue } from "../repository-client.js"; +import { ConnectSourceRepository } from "../source-repository.js"; + +import { nextReaderClient } from "./client.js"; +import { nextErrorOf, nextProblem, waitingForDeviceMessage } from "./errors.js"; + +import type { QueryPage } from "@mdbase-dev/connect"; + +/** Just enough of the expression language for Reader's own filters. */ +function where(expression: string, record: MemoryRecord): boolean { + const equals = /^(file\.path|id) == "([^"]*)"$/u.exec(expression); + if (equals) { + const value = + equals[1] === "file.path" ? record.path : toPlain(record.frontmatter.get("id") ?? null); + return value === equals[2]; + } + const contains = /^file\.body\.lower\(\)\.contains\("([^"]*)"\)$/u.exec(expression); + if (contains) { + return record.body.toLowerCase().includes(contains[1] ?? ""); + } + throw new Error(`unsupported test expression: ${expression}`); +} + +const clients: MdbaseClient[] = []; + +interface Opened { + readonly replica: MemoryReplica; + readonly db: MdbaseClient; + readonly client: ReturnType; +} + +async function open(options: ConstructorParameters[0] = {}): Promise { + const replica = new MemoryReplica({ confirmDelayMs: null, where, ...options }); + const db = await connect({ + app: { name: "mdbase-reader-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, db, client: nextReaderClient(db) }; +} + +function seedSource(replica: MemoryReplica, index: number, body = ""): MemoryRecord { + return replica.seed({ + path: `sources/source-${String(index)}.md`, + types: ["reader-source"], + frontmatter: { id: `src-${String(index)}`, title: `Source ${String(index)}` }, + body, + }); +} + +afterEach(() => { + for (const db of clients.splice(0)) { + db.close(); + } +}); + +describe("mdbase-next Reader client", () => { + it("reads a record by path as a Connect record document", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "Notes"); + + const document = outcomeValue( + await client.read({ path: "sources/source-1.md", includeDocument: true }), + "read", + ); + + expect(document).toMatchObject({ + path: "sources/source-1.md", + types: ["reader-source"], + frontmatter: { id: "src-1", title: "Source 1" }, + effectiveFrontmatter: { id: "src-1", title: "Source 1" }, + body: "Notes", + file: { path: "sources/source-1.md", name: "source-1.md", folder: "sources" }, + }); + expect(document.revision).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(document.document).toContain("Notes"); + }); + + it("pages queries by cursor and keeps Reader's page sizes", async () => { + const { replica, client } = await open(); + for (let index = 1; index <= 5; index += 1) { + seedSource(replica, index); + } + + const pages: QueryPage[] = []; + for await (const outcome of client.queryPages( + { contract: sourceContract, frontmatterMode: "effective" }, + { firstPageSize: 2, pageSize: 2 }, + )) { + pages.push(outcomeValue(outcome, "query")); + } + + expect(pages.map((page) => page.results.length)).toEqual([2, 2, 1]); + expect(pages.map((page) => page.complete)).toEqual([false, false, true]); + expect(pages.at(-1)).toMatchObject({ page: 2, offset: 4, loaded: 5 }); + expect(pages.flatMap((page) => page.results.map((row) => row.path))).toHaveLength(5); + }); + + it("emulates offset pages for Reader's library list", async () => { + const { replica, client } = await open(); + for (let index = 1; index <= 5; index += 1) { + seedSource(replica, index); + } + + const page = outcomeValue( + await client.query({ contract: sourceContract, limit: 2, offset: 2 }), + "query", + ); + + expect(page.results.map((row) => row.path)).toEqual([ + "sources/source-3.md", + "sources/source-4.md", + ]); + expect(page.meta?.hasMore).toBe(true); + }); +}); + +describe("mdbase-next Reader client writes", () => { + it("returns created and updated records optimistically, then they confirm", async () => { + const { replica, db, client } = await open(); + + const created = outcomeValue( + await client.create({ + path: "sources/new.md", + contract: sourceContract, + frontmatter: { id: "src-new", title: "New" }, + body: "Draft", + }), + "create", + ); + expect(created).toMatchObject({ + path: "sources/new.md", + types: ["reader-source"], + body: "Draft", + }); + expect((await db.get({ path: "sources/new.md" })).state.state).toBe("pending"); + + const updated = outcomeValue( + await client.update({ path: "sources/new.md", patch: { title: "Renamed" }, body: "Final" }), + "update", + ); + expect(updated).toMatchObject({ frontmatter: { title: "Renamed" }, body: "Final" }); + + replica.confirmAll(); + await vi.waitFor(async () => { + expect((await db.get({ path: "sources/new.md" })).state.state).toBe("confirmed"); + }); + }); + + it("waits for confirmation when Reader asks for CAS", async () => { + const { replica, client } = await open({ confirmDelayMs: 5 }); + seedSource(replica, 1, "Before"); + const current = outcomeValue(await client.read({ path: "sources/source-1.md" }), "read"); + + const updated = outcomeValue( + await client.update({ + path: current.path, + ifRevision: current.revision, + patch: {}, + body: "After", + }), + "update", + ); + + expect(updated.body).toBe("After"); + expect(replica.allRecords[0]?.pending).toBe(false); + }); + + it("maps a CAS refusal to Reader's concurrent-modification problem", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "Before"); + const stale = outcomeValue(await client.read({ path: "sources/source-1.md" }), "read"); + outcomeValue( + await client.update({ path: stale.path, patch: { title: "Elsewhere" } }), + "concurrent edit", + ); + + const outcome = await client.update({ + path: stale.path, + ifRevision: stale.revision, + patch: {}, + body: "Mine", + }); + + expect(outcome.ok).toBe(false); + if (outcome.ok) { + return; + } + expect(outcome.problem.code).toBe("concurrent_modification"); + expect(nextErrorOf(outcome.problem)).toMatchObject({ code: "conflict", reason: "revision" }); + expect(() => outcomeValue(outcome, "save source note")).toThrow(ConnectRepositoryError); + }); + + it("serves Reader's source repository and content search unchanged", async () => { + const { replica, client } = await open(); + seedSource(replica, 1, "An essay about Marginalia."); + seedSource(replica, 2, "Unrelated"); + const collection = collectionId(replica.collection); + + const source = await new ConnectSourceRepository(client).get(collection, sourceId("src-1")); + const matches = await new ConnectContentSearchRepository(client).search( + collection, + "marginalia", + ); + + expect(source).toMatchObject({ id: "src-1", title: "Source 1", path: "sources/source-1.md" }); + expect(matches.map((match) => match.sourceId)).toEqual(["src-1"]); + expect(matches[0]?.passages?.[0]?.text).toContain("Marginalia"); + }); + + it("deletes with progress and preflights with a dry run", async () => { + const { replica, client } = await open(); + const record = seedSource(replica, 1); + const revision = outcomeValue(await client.read({ path: record.path }), "read").revision; + + const preflight = outcomeValue( + await client.preflightDelete({ path: record.path, ifRevision: revision }), + "preflight", + ); + expect(preflight).toEqual({ + path: record.path, + deleted: false, + dryRun: true, + wouldDelete: true, + }); + expect(replica.allRecords).toHaveLength(1); + + const progress = vi.fn(); + const deleted = outcomeValue( + await client.deleteWithProgress({ path: record.path }, { onProgress: progress }), + "delete", + ); + expect(deleted).toEqual({ path: record.path, deleted: true }); + expect(progress).toHaveBeenLastCalledWith(expect.objectContaining({ state: "completed" })); + const missing = await client.read({ path: record.path }); + expect(missing.ok ? null : missing.problem.code).toBe("file_not_found"); + }); +}); + +describe("mdbase-next problems", () => { + it("shows a waiting state when none of the person's devices is online", () => { + const problem = nextProblem(mdbaseError("unavailable", "no device online", "no_device_online")); + + expect(problem.code).toBe("connector_offline"); + expect(problem.message).toBe(waitingForDeviceMessage); + }); + + it.each([ + ["invalid_request", "invalid_request"], + ["not_found", "file_not_found"], + ["unauthenticated", "authorization_expired"], + ["forbidden", "insufficient_access"], + ["rate_limited", "rate_limited"], + ["upgrade_required", "connector_upgrade_required"], + ["outcome_unknown", "operation_outcome_unknown"], + ["cancelled", "operation_cancelled"], + ["internal", "operation_failed"], + ] as const)("maps %s to %s", (code, expected) => { + expect(nextProblem(mdbaseError(code, "detail")).code).toBe(expected); + }); +}); diff --git a/packages/connect/src/next/client.ts b/packages/connect/src/next/client.ts new file mode 100644 index 0000000..1dfb3c2 --- /dev/null +++ b/packages/connect/src/next/client.ts @@ -0,0 +1,377 @@ +// mdbase-next backend: ReaderConnectClient over the new SDK's MdbaseClient. +import { connectFailure, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { + isMdbaseError, + mdbaseError, + type MdbaseClient, + type Include, + type MdbaseError, + type PlainValue, + type Query, + type RecordView, + type Write, +} from "@mdbase-dev/sdk"; + +import { readerDiagnostics } from "../diagnostics.js"; +import { mapConcurrent } from "../repository-client.js"; + +import { asMdbaseError, nextOutcome, nextProblem } from "./errors.js"; +import { nextQuery, queryRecord, readInclude, recordDocument, typeForContract } from "./records.js"; + +import type { ReaderConnectClient, ReaderQueryPagesOptions } from "../repository-client.js"; +import type { + ConnectOutcome, + CreateInput, + DeleteInput, + DeletePreflightResult, + DeleteProgressOptions, + DeleteResult, + JsonObject, + QueryInput, + QueryMetadataInput, + QueryMetadataPage, + QueryMetadataRecord, + QueryPage, + QueryRecord, + QueryResult, + ReadManyEntry, + ReadManyOptions, + ReadManyResult, + RecordDocument, + UpdateInput, +} from "@mdbase-dev/connect"; +import type { ReaderRequestOptions } from "@mdbase-reader/core"; + +export interface NextReaderClientOptions { + /** + * Writes that carry `ifRevision` wait for confirmation (default), so a CAS refusal + * at head still reaches Reader as a `conflict`. Other writes return optimistically. + */ + readonly confirmCasWrites?: boolean; + /** An optimistic write was rejected after it returned. */ + readonly onRejected?: (error: MdbaseError, path: string) => void; +} + +/** Reader features this backend answers; read-many returns revision-qualified records. */ +const supportedFeatures = new Set(["read-many-documents-v1"]); + +const route = (): string => "relay"; + +function signalOf(options?: ReaderRequestOptions): AbortSignal | undefined { + return options?.signal; +} + +/** + * Keeps Reader's repositories independent of SDK types. Reads map to `get`/`query`, + * writes to field-level `create`/`update`/`delete` with opt-in CAS. + */ +export function nextReaderClient( + db: MdbaseClient, + options: NextReaderClientOptions = {}, +): ReaderConnectClient { + const writes = new NextWrites(db, options); + function queryPages( + input: QueryMetadataInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable>; + function queryPages( + input: QueryInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable>; + function queryPages( + input: QueryInput | QueryMetadataInput, + paging?: ReaderQueryPagesOptions, + ): AsyncIterable> { + return readerDiagnostics.pages(route, nextPages(db, input, paging)); + } + return { + supportsAuthorityFeature: (id) => Promise.resolve(connectSuccess(supportedFeatures.has(id))), + read: (input, request) => + readerDiagnostics.measure("read", route, () => + nextOutcome(async () => + recordDocument( + await db.get( + { path: input.path }, + readInclude({ includeBody: true, includeDocument: input.includeDocument ?? false }), + signalOf(request), + ), + ), + ), + ), + readMany: (paths, request) => + readerDiagnostics.measure("read-many", route, () => readMany(db, paths, request)), + query: (input, request) => + readerDiagnostics.measure("query", route, () => + nextOutcome(() => queryOnce(db, input, signalOf(request))), + ), + queryPages, + create: (input) => + readerDiagnostics.measure("create", route, () => nextOutcome(() => writes.create(input))), + update: (input) => + readerDiagnostics.measure("update", route, () => nextOutcome(() => writes.update(input))), + preflightDelete: (input) => + readerDiagnostics.measure("delete-preflight", route, () => + nextOutcome(() => writes.preflightDelete(input)), + ), + deleteWithProgress: (input, progress) => + readerDiagnostics.measure("delete", route, () => + nextOutcome(() => writes.delete(input, progress)), + ), + }; +} + +type BatchFailure = ReadManyResult["errors"][number]["failure"]; + +async function readMany( + db: MdbaseClient, + paths: readonly string[], + options: ReadManyOptions = {}, +): Promise> { + const include = readInclude(options); + const errors: ReadManyResult["errors"] = []; + const results = await mapConcurrent( + paths, + Math.min(Math.max(options.concurrency ?? 4, 1), 4), + async (path, batch): Promise => { + try { + const view = await db.find({ path }, include, options.signal); + const wanted = !options.types || view?.types.some((type) => options.types?.includes(type)); + return view && wanted + ? { status: "found", path, record: queryRecord(view) } + : { status: "missing", path }; + } catch (error) { + const problem = nextProblem(asMdbaseError(error)); + if (problem.code === "operation_cancelled") { + throw error; + } + errors.push({ + batch, + paths: [path], + failure: connectFailure(problem as BatchFailure["problem"]), + }); + return { status: "error", path, batch }; + } + }, + ); + return connectSuccess({ results, errors }); +} + +/** One query page. Offsets are emulated by reading past them: the replica pages by cursor. */ +async function queryOnce( + db: MdbaseClient, + input: QueryInput, + signal?: AbortSignal, +): Promise { + const offset = input.offset ?? 0; + const query: Query = { + ...nextQuery(input), + ...(input.limit === undefined ? {} : { limit: offset + input.limit }), + ...(input.cursor ? { cursor: input.cursor } : {}), + }; + const page = await db.query(query, readInclude(input), signal); + const results = page.records.slice(offset).map(queryRecord); + const hasMore = page.cursor !== undefined; + return { + results, + meta: { + hasMore, + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(hasMore ? {} : { totalCount: offset + results.length }), + }, + }; +} + +function metadataRecord(row: QueryRecord, select: QueryInput["select"]): QueryMetadataRecord { + const fields = row.effectiveFrontmatter ?? row.frontmatter ?? {}; + const values: JsonObject = {}; + for (const entry of select ?? []) { + const name = typeof entry === "string" ? entry : entry.name; + const value = fields[name]; + if (!name.startsWith("projection.") && value !== undefined) { + values[name] = value; + } + } + return { path: row.path, types: row.types, revision: row.revision ?? "", values }; +} + +async function* nextPages( + db: MdbaseClient, + input: QueryInput | QueryMetadataInput, + options: ReaderQueryPagesOptions = {}, +): AsyncGenerator> { + const query = nextQuery(input); + const include = input.output === "metadata" ? {} : readInclude(input); + const firstSize = options.firstPageSize ?? options.pageSize ?? 200; + let cursor: string | undefined; + let loaded = 0; + for (let page = 0; ; page += 1) { + let result; + try { + result = await db.query( + { + ...query, + limit: page === 0 ? firstSize : (options.pageSize ?? firstSize), + ...(cursor ? { cursor } : {}), + }, + include, + options.signal, + ); + } catch (error) { + yield connectFailure(nextProblem(asMdbaseError(error))); + return; + } + const rows = result.records.map(queryRecord); + const offset = loaded; + loaded += rows.length; + cursor = result.cursor; + const common = { + page, + offset, + loaded, + complete: cursor === undefined, + meta: { hasMore: cursor !== undefined, ...(cursor ? { cursor } : {}) }, + ...(cursor ? { cursor } : {}), + }; + yield input.output === "metadata" + ? connectSuccess({ + ...common, + output: "metadata" as const, + results: rows.map((row) => metadataRecord(row, input.select)), + }) + : connectSuccess({ ...common, results: rows }); + if (!cursor) { + return; + } + } +} + +/** Field-level writes. The SDK fills in base values and body edits from the view it was given. */ +class NextWrites { + constructor( + private readonly db: MdbaseClient, + private readonly options: NextReaderClientOptions, + ) {} + + async create(input: CreateInput): Promise { + const type = input.type ?? typeForContract(input.contract); + const write = await this.db.create( + { + ...(type ? { type } : {}), + ...(input.path ? { path: input.path } : {}), + frontmatter: (input.frontmatter ?? {}) as Record, + ...(input.body === undefined ? {} : { body: input.body }), + }, + { + include: readInclude({ + includeBody: true, + includeDocument: input.includeDocument ?? false, + }), + }, + ); + return recordDocument(await this.settle(write, input.path ?? "", false)); + } + + async update(input: UpdateInput): Promise { + const replacing = input.document !== undefined; + const current = await this.db.get( + { path: input.path }, + { body: true, ...(replacing ? { document: true } : {}) }, + ); + const include = readInclude({ + includeBody: true, + includeDocument: replacing || (input.includeDocument ?? false), + }); + const cas = input.ifRevision ? { ifRevision: input.ifRevision } : {}; + const write = + input.document !== undefined + ? await this.db.replaceDocument(current, input.document, { ...cas, include }) + : await this.db.update( + current, + { + patch: input.patch as Record, + ...(input.body === undefined ? {} : { body: input.body }), + ...cas, + }, + { include }, + ); + return recordDocument(await this.settle(write, input.path, Boolean(input.ifRevision), include)); + } + + /** A dry-run delete: the replica plans it without capturing anything. */ + async preflightDelete(input: DeleteInput): Promise { + const current = await this.db.get({ path: input.path }); + const write = await this.db.delete(current, { + dryRun: true, + ...(input.ifRevision ? { ifRevision: input.ifRevision } : {}), + }); + await rejectedNow(write); + // Backlink discovery has no equivalent in the client API yet: no broken links are reported. + return { path: input.path, deleted: false, dryRun: true, wouldDelete: true } as const; + } + + async delete(input: DeleteInput, progress: DeleteProgressOptions = {}): Promise { + const started = Date.now(); + const report = (state: "applying" | "completed", completedUnits: number): void => + progress.onProgress?.({ + operation: "delete", + state, + elapsedMs: Date.now() - started, + cancellable: false, + resumed: false, + completedUnits, + }); + report("applying", 0); + const current = await this.db.get({ path: input.path }, undefined, progress.signal); + const write = await this.db.delete(current, { + ...(input.ifRevision ? { ifRevision: input.ifRevision } : {}), + }); + await rejectedNow(write); + if (input.ifRevision && this.options.confirmCasWrites !== false) { + await write.confirmed; + } else { + this.watch(write, input.path); + } + report("completed", 1); + return { path: input.path, deleted: true }; + } + + /** The record a write produced: confirmed for CAS writes, otherwise optimistic. */ + private async settle( + write: Write, + path: string, + cas: boolean, + include?: Include, + ): Promise { + await rejectedNow(write); + if (cas && this.options.confirmCasWrites !== false) { + await write.confirmed; + // Confirmed receipts carry no bodies: read the confirmed state back. + return this.db.get(firstRecord(write.records).id, include); + } + this.watch(write, path); + return firstRecord(write.records); + } + + private watch(write: Write, path: string): void { + write.confirmed.catch((error: unknown) => { + if (isMdbaseError(error)) { + this.options.onRejected?.(error, path); + } + }); + } +} + +/** A receipt rejected at submit throws its problem now. */ +async function rejectedNow(write: Write): Promise { + if (write.state === "rejected" || write.state === "unknown") { + await write.confirmed; + } +} + +function firstRecord(records: readonly RecordView[]): RecordView { + const [record] = records; + if (!record) { + throw mdbaseError("internal", "The replica returned no record for this write."); + } + return record; +} diff --git a/packages/connect/src/next/collection.ts b/packages/connect/src/next/collection.ts new file mode 100644 index 0000000..29c8df4 --- /dev/null +++ b/packages/connect/src/next/collection.ts @@ -0,0 +1,158 @@ +// mdbase-next backend: one opened collection, assembled from Reader's existing repositories. +import { connectFailure, connectProblem } from "@mdbase-dev/connect/advanced"; +import { toPlain, type MdbaseClient, type PlainValue } from "@mdbase-dev/sdk"; +import { collectionId } from "@mdbase-reader/core"; + +import { ConnectAnnotationAssetRepository } from "../annotation-assets.js"; +import { ConnectCollectionFileRepository } from "../collection-files.js"; +import { ConnectDocumentRepository } from "../documents.js"; +import { + ConnectAnnotationRepository, + ConnectContentSearchRepository, + ConnectRepositoryError, + ConnectSourceRepository, + outcomeValue, +} from "../repositories.js"; +import { ConnectSourceImportRepository } from "../source-imports.js"; + +import { nextReaderClient, type NextReaderClientOptions } from "./client.js"; +import { nextRepositoryError } from "./errors.js"; +import { nextReaderFiles } from "./files.js"; +import { NextMigrationTarget } from "./migration-target.js"; + +import type { + ReaderConnectedCollection, + ReaderDirectAccessController, +} from "../application-session.js"; +import type { LibraryViewRepository } from "../library-views.js"; +import type { ReaderConnectClient } from "../repository-client.js"; +import type { ReaderNextGrant } from "./control-plane.js"; +import type { + ConnectOutcome, + DirectAccessProblemCode, + DirectAccessStatus, + JsonObject, + SavedViewExecution, + SavedViewList, +} from "@mdbase-dev/connect"; +import type { BodyUpdateRecovery } from "@mdbase-reader/core"; + +/** + * Reader's repositories over an mdbase-next client. Source, annotation, search, + * import and file repositories are the existing ones on the new seams; the rest + * are stubs until the new client API has an equivalent. + */ +export function nextReaderCollection( + db: MdbaseClient, + grant: Pick, + options: NextReaderClientOptions = {}, +): ReaderConnectedCollection { + const client = nextReaderClient(db, options); + const files = nextReaderFiles(db); + return { + collectionId: collectionId(grant.collectionId), + collectionName: grant.displayName, + sources: new ConnectSourceRepository(client), + sourceImports: new ConnectSourceImportRepository(client, files), + migration: new NextMigrationTarget(db), + annotations: new ConnectAnnotationRepository(client), + annotationAssets: new ConnectAnnotationAssetRepository(files), + documents: new ConnectDocumentRepository(files), + contentSearch: new ConnectContentSearchRepository(client), + files: new ConnectCollectionFileRepository(files), + libraryViews: nextLibraryViews(db, client), + directAccess: noDirectAccess, + bodyRecovery: noBodyRecovery, + }; +} + +function unsupported(feature: string): Error { + return new ConnectRepositoryError( + feature, + "unsupported_operation", + `${feature} is not available on the mdbase-next backend yet`, + ); +} + +/** + * STUB: receipts replace interrupted-write recovery. Writes are resubmitted by mutation ID + * inside the SDK, so nothing is ever left for Reader to recover. + */ +const noBodyRecovery: BodyUpdateRecovery = { + recoverSource: () => Promise.reject(unsupported("Recovering an interrupted write")), + recoverAnnotation: () => Promise.reject(unsupported("Recovering an interrupted write")), + pending: () => false, +}; + +/** STUB: there is no loopback direct-access route; sessions go through the relay. */ +const noDirectAccess: ReaderDirectAccessController = { + getSnapshot: () => null, + subscribe: () => () => undefined, + disable: () => undefined, + check: () => Promise.resolve(directAccessUnavailable()), + request: () => Promise.resolve(directAccessUnavailable()), +}; + +function directAccessUnavailable(): ConnectOutcome { + return connectFailure( + connectProblem("operation_failed", "Direct access is not used by the mdbase-next backend."), + ); +} + +function object(value: unknown): Readonly> | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : null; +} + +/** + * Saved views over `listViews`/`executeView`. The replica's view payloads are not + * pinned down in the client API contract yet, so they are read defensively: an + * unrecognized list is empty, and an unrecognized execution is refused. + */ +function nextLibraryViews(db: MdbaseClient, client: ReaderConnectClient): LibraryViewRepository { + return { + async list(options = {}) { + const body = object(toPlain(await db.listViews(options.signal).catch(rethrow("list views")))); + const views = Array.isArray(body?.["views"]) ? (body["views"] as SavedViewList["views"]) : []; + return { views, meta: { totalCount: views.length } }; + }, + async execute(input, options = {}) { + const body = object( + toPlain( + await db + .executeView(input as unknown as PlainValue, options.signal) + .catch(rethrow("run a library view")), + ), + ); + if (!Array.isArray(body?.["results"]) || !object(body["meta"])) { + throw unsupported("Running this library view"); + } + return body as unknown as SavedViewExecution; + }, + async save({ path, frontmatter, replace }) { + const saved = replace + ? outcomeValue( + await client.update({ + path, + document: await viewDocument(frontmatter), + ...(replace.revision ? { ifRevision: replace.revision } : {}), + }), + "save library view", + ) + : outcomeValue(await client.create({ path, frontmatter }), "create library view"); + return { path: saved.path, revision: saved.revision }; + }, + }; +} + +function rethrow(operation: string): (error: unknown) => never { + return (error) => { + throw nextRepositoryError(operation, error); + }; +} + +async function viewDocument(frontmatter: JsonObject): Promise { + const { stringify } = await import("yaml"); + return `---\n${stringify(frontmatter).trimEnd()}\n---\n\n`; +} diff --git a/packages/connect/src/next/control-plane.ts b/packages/connect/src/next/control-plane.ts new file mode 100644 index 0000000..ae873f0 --- /dev/null +++ b/packages/connect/src/next/control-plane.ts @@ -0,0 +1,161 @@ +// mdbase-next backend: the one place Reader talks to the control plane. +// +// DEPENDENCY (control workstream): neither the consent flow that registers this +// client's key (`client_pk`) in a grant nor the routing endpoint exists yet. The +// HTTP implementation below targets a *proposed* endpoint and must follow the +// control workstream's final contract. +import { mdbaseError, type RelayRoute } from "@mdbase-dev/sdk"; + +/** A grant this client's key holds for one collection. */ +export interface ReaderNextGrant { + readonly collectionId: string; + readonly grant: string; + readonly displayName: string; +} + +export interface ReaderNextControlPlane { + /** The grants registered for this client key. */ + grants(clientPublicKey: Uint8Array): Promise; + /** + * Ask the person to consent. Consent registers `clientPublicKey` as the grant's + * `client_pk`. Resolves `null` when no grant was made. + */ + authorize(clientPublicKey: Uint8Array): Promise; + /** + * Where the collection's replica can be reached now. `null` means no target is + * online: for a private collection, none of the person's devices. + */ + resolveRoute(collectionId: string): Promise; +} + +export const nextGrantsStorageKey = "mdbase-reader:next-grants"; + +export interface ProposedControlPlaneOptions { + readonly serverUrl: string; + /** Where grants made out of band are remembered until a consent flow exists. */ + readonly storage?: Storage | null; + readonly fetch?: typeof fetch; +} + +/** + * PROPOSED control-plane client. + * - Route: `GET {serverUrl}/v1/next/collections/:id/route` → + * `{targets: [{url, device, noise_pk}]}` (`noise_pk` base64 or base64url, 32 bytes). + * - Grants: read from local storage. The consent flow that registers `client_pk` + * is not defined yet, so `authorize` makes no grant. + */ +export class ProposedHttpControlPlane implements ReaderNextControlPlane { + readonly #serverUrl: string; + readonly #storage: Storage | null; + readonly #fetch: typeof fetch; + + public constructor(options: ProposedControlPlaneOptions) { + this.#serverUrl = options.serverUrl.replace(/\/+$/u, ""); + this.#storage = options.storage ?? null; + this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init)); + } + + public grants(): Promise { + return Promise.resolve(parseGrants(this.#storage?.getItem(nextGrantsStorageKey) ?? null)); + } + + public authorize(): Promise { + return Promise.resolve(null); + } + + /** Remembers a grant made out of band (for example by a development consent tool). */ + public rememberGrant(grant: ReaderNextGrant): void { + const grants = parseGrants(this.#storage?.getItem(nextGrantsStorageKey) ?? null).filter( + (known) => known.collectionId !== grant.collectionId, + ); + this.#storage?.setItem(nextGrantsStorageKey, JSON.stringify([...grants, grant])); + } + + public async resolveRoute(collectionId: string): Promise { + const url = `${this.#serverUrl}/v1/next/collections/${encodeURIComponent(collectionId)}/route`; + let response: Response; + try { + response = await this.#fetch(url, { credentials: "include" }); + } catch (error) { + throw mdbaseError("unavailable", `control plane unreachable: ${String(error)}`, "no_route"); + } + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw mdbaseError( + "unavailable", + `route lookup failed: ${String(response.status)}`, + "no_route", + ); + } + return routeFrom(await response.json()); + } +} + +function record(value: unknown): Readonly> | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : null; +} + +/** The first target of a route response, or `null` when none is online. */ +export function routeFrom(body: unknown): RelayRoute | null { + const targets = record(body)?.["targets"]; + if (!Array.isArray(targets)) { + throw mdbaseError("unavailable", "malformed route response", "no_route"); + } + for (const candidate of targets) { + const target = record(candidate); + const url = target?.["url"]; + const device = target?.["device"]; + const key = target?.["noise_pk"]; + if (typeof url === "string" && typeof device === "string" && typeof key === "string") { + const noisePublicKey = base64Bytes(key); + if (noisePublicKey.length === 32) { + return { url, targetDevice: device, noisePublicKey }; + } + } + } + return null; +} + +function base64Bytes(text: string): Uint8Array { + const normalized = text.replaceAll("-", "+").replaceAll("_", "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + try { + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); + } catch { + return new Uint8Array(); + } +} + +function parseGrants(text: string | null): ReaderNextGrant[] { + if (!text) { + return []; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + return parsed.flatMap((candidate: unknown) => { + const grant = record(candidate); + const collectionId = grant?.["collectionId"]; + const id = grant?.["grant"]; + const displayName = grant?.["displayName"]; + return typeof collectionId === "string" && typeof id === "string" + ? [ + { + collectionId, + grant: id, + displayName: typeof displayName === "string" ? displayName : collectionId, + }, + ] + : []; + }); +} diff --git a/packages/connect/src/next/errors.ts b/packages/connect/src/next/errors.ts new file mode 100644 index 0000000..04ff7db --- /dev/null +++ b/packages/connect/src/next/errors.ts @@ -0,0 +1,156 @@ +// mdbase-next backend: the 15 replica error codes as Reader's Connect problems. +import { connectFailure, connectProblem, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, mdbaseError, type ErrorCode, type MdbaseError } from "@mdbase-dev/sdk"; + +import { ConnectRepositoryError } from "../repository-client.js"; + +import type { ConnectFailure, ConnectOutcome } from "@mdbase-dev/connect"; + +type ConnectProblem = ConnectFailure["problem"]; + +/** Shown while a private collection has none of the person's devices online. */ +export const waitingForDeviceMessage = + "Waiting for one of your devices to come online. Reader opens the collection as soon as one does."; + +/** What a person reads for each code; apps show their own text keyed on code and reason. */ +const messages: Readonly> = { + invalid_request: "mdbase did not accept Reader’s request.", + invalid_record: "mdbase did not accept this record because it is not valid.", + not_found: "This record or file no longer exists.", + conflict: "The record changed before Reader saved it. Reload it and try again.", + unauthenticated: "Reader’s access to this collection has expired. Reconnect to keep reading.", + forbidden: "Reader’s access to this collection does not allow this.", + collection_invalid: "This collection’s configuration needs repair before Reader can write to it.", + unavailable: "mdbase is not reachable right now. Reader will retry.", + rate_limited: "mdbase asked Reader to slow down. Try again in a moment.", + quota_exceeded: "This collection is out of storage space.", + too_large: "This is larger than mdbase accepts.", + upgrade_required: "This collection needs a newer version of Reader.", + outcome_unknown: "Reader could not tell whether the last change was saved. Reload to check.", + cancelled: "The request was cancelled.", + internal: "mdbase hit an unexpected problem. Try again once.", +}; + +// The originating replica error for each mapped problem, for callers that branch on it. +const origins = new WeakMap(); + +/** True when `error` means "no device of this private collection is online". */ +export function isWaitingForDevice(error: unknown): boolean { + return isMdbaseError(error, "unavailable") && error.reason === "no_device_online"; +} + +/** Reader's text for a replica error. */ +export function nextProblemMessage(error: MdbaseError): string { + if (isWaitingForDevice(error)) { + return waitingForDeviceMessage; + } + if (error.code === "conflict" && error.reason === "path_taken") { + return "Another record already uses this path."; + } + return messages[error.code]; +} + +/** + * Maps a replica error onto the closest Connect problem so the existing + * `ConnectRepositoryError` and `connectProblemMessage` UI keep working. + */ +export function nextProblem(error: MdbaseError): ConnectProblem { + const message = nextProblemMessage(error); + const problem = mappedProblem(error, message); + origins.set(problem, error); + return problem; +} + +/** The replica error a problem was mapped from, if it came from the mdbase-next backend. */ +export function nextErrorOf(problem: ConnectProblem): MdbaseError | undefined { + return origins.get(problem); +} + +// One branch per code is the clearest form of the table. +// eslint-disable-next-line complexity +function mappedProblem(error: MdbaseError, message: string): ConnectProblem { + switch (error.code) { + case "invalid_request": + case "too_large": + return connectProblem("invalid_request", message); + case "invalid_record": + return connectProblem("operation_invalid", message, { + details: { diagnostics: (error.issues ?? []).map((issue) => ({ ...issue })) }, + }); + case "not_found": + return connectProblem("file_not_found", message); + case "conflict": + return error.reason === "path_taken" + ? connectProblem("path_occupied", message) + : connectProblem("concurrent_modification", message); + case "unauthenticated": + return connectProblem("authorization_expired", message); + case "forbidden": + return connectProblem("insufficient_access", message, { + details: { required_operations: [], granted_operations: [], missing_operations: [] }, + }); + case "collection_invalid": + return connectProblem("collection_invalid", message, { + details: { diagnostics: (error.issues ?? []).map((issue) => ({ ...issue })) }, + }); + case "unavailable": + return isWaitingForDevice(error) + ? connectProblem("connector_offline", message, { details: {} }) + : connectProblem("temporarily_unavailable", message); + case "rate_limited": + return connectProblem("rate_limited", message, { + ...(error.retryAfterMs === undefined + ? {} + : { details: { retry_after_ms: error.retryAfterMs } }), + }); + case "upgrade_required": + return connectProblem("connector_upgrade_required", message); + case "outcome_unknown": + return connectProblem("operation_outcome_unknown", message, { + details: { request_id: error.traceId ?? "" }, + }); + case "cancelled": + return connectProblem("operation_cancelled", message); + case "quota_exceeded": + case "internal": + return connectProblem("operation_failed", message); + } +} + +/** Any thrown value as a replica error (DOM aborts become `cancelled`). */ +export function asMdbaseError(error: unknown): MdbaseError { + if (isMdbaseError(error)) { + return error; + } + if (error instanceof DOMException && error.name === "AbortError") { + return mdbaseError("cancelled", error.message); + } + throw error; +} + +/** Runs an SDK call as a Connect outcome; replica errors become failures, bugs still throw. */ +export async function nextOutcome( + operation: () => Promise, +): Promise> { + try { + return connectSuccess(await operation()); + } catch (error) { + return connectFailure(nextProblem(asMdbaseError(error))); + } +} + +/** A Connect failure for something the mdbase-next client API has no equivalent for yet. */ +export function unsupportedOnNext(feature: string): ConnectFailure { + return connectFailure( + connectProblem( + "unsupported_operation", + `${feature} is not available on the mdbase-next backend yet.`, + ), + ); +} + +/** A replica error as the error Reader's repositories throw (bugs still throw as they are). */ +export function nextRepositoryError(operation: string, error: unknown): ConnectRepositoryError { + const problem = nextProblem(asMdbaseError(error)); + return new ConnectRepositoryError(operation, problem.code, problem.message, problem); +} diff --git a/packages/connect/src/next/files.test.ts b/packages/connect/src/next/files.test.ts new file mode 100644 index 0000000..8610172 --- /dev/null +++ b/packages/connect/src/next/files.test.ts @@ -0,0 +1,94 @@ +import { connect, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { collectionId, fileRevision } from "@mdbase-reader/core"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ConnectCollectionFileRepository } from "../collection-files.js"; +import { outcomeValue } from "../repository-client.js"; + +import { nextReaderFiles, transferUuid } from "./files.js"; + +import type { CollectionFileDescriptor } from "@mdbase-dev/connect"; + +const clients: MdbaseClient[] = []; + +async function open(): Promise<{ + readonly replica: MemoryReplica; + readonly files: ReturnType; +}> { + const replica = new MemoryReplica({ confirmDelayMs: null }); + const db = await connect({ + app: { name: "mdbase-reader-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, files: nextReaderFiles(db) }; +} + +afterEach(() => { + for (const db of clients.splice(0)) { + db.close(); + } +}); + +const pdf = new TextEncoder().encode("%PDF-1.7 test document"); + +describe("mdbase-next Reader files", () => { + it("uploads, lists, stats and downloads a file", async () => { + const { files } = await open(); + + const uploaded = await files.upload("documents/paper.pdf", new Blob([pdf]), { + mediaType: "application/pdf", + transferId: "reader-import:paper", + }); + expect(uploaded).toMatchObject({ + path: "documents/paper.pdf", + size: pdf.length, + mediaType: "application/pdf", + mediaClass: "pdf", + }); + expect(uploaded.contentDigest).toMatch(/^sha256:[0-9a-f]{64}$/u); + + const listed: CollectionFileDescriptor[] = []; + for await (const file of files.list?.({ folder: "documents" }) ?? []) { + listed.push(file); + } + expect(listed.map((file) => file.path)).toEqual(["documents/paper.pdf"]); + + const byId = outcomeValue(await files.stat({ fileId: uploaded.fileId }), "stat"); + const byPath = outcomeValue(await files.stat({ path: "documents/paper.pdf" }), "stat"); + const missing = outcomeValue(await files.stat({ path: "documents/none.pdf" }), "stat"); + expect(byId?.fileId).toBe(uploaded.fileId); + expect(byPath?.fileId).toBe(uploaded.fileId); + expect(missing).toBeNull(); + + const blob = await files.download(uploaded); + expect(blob.type).toBe("application/pdf"); + expect(new Uint8Array(await blob.arrayBuffer())).toEqual(pdf); + }); + + it("feeds Reader's collection file repository", async () => { + const { replica, files } = await open(); + const uploaded = await files.upload("documents/paper.pdf", new Blob([pdf])); + + const exported = await new ConnectCollectionFileRepository(files).read( + collectionId(replica.collection), + "[[documents/paper.pdf]]", + fileRevision(uploaded.contentDigest), + ); + + expect(exported.mediaType).toBe("application/pdf"); + expect(exported.bytes).toEqual(pdf); + }); + + it("derives stable transfer UUIDs from Reader's retry keys", async () => { + const first = await transferUuid("reader-import:paper"); + + expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u); + expect(await transferUuid("reader-import:paper")).toBe(first); + expect(await transferUuid("0190A0B0-0000-7000-8000-000000000000")).toBe( + "0190a0b0-0000-7000-8000-000000000000", + ); + }); +}); diff --git a/packages/connect/src/next/files.ts b/packages/connect/src/next/files.ts new file mode 100644 index 0000000..0d396ab --- /dev/null +++ b/packages/connect/src/next/files.ts @@ -0,0 +1,168 @@ +// mdbase-next backend: Reader's file seams over the new SDK's FilesApi. +import { connectSuccess } from "@mdbase-dev/connect/advanced"; +import { isMdbaseError, uuidv7, type FileView, type MdbaseClient } from "@mdbase-dev/sdk"; + +import { asMdbaseError, nextOutcome, nextRepositoryError } from "./errors.js"; + +import type { ReaderAssetClient } from "../annotation-assets.js"; +import type { ReaderFileClient } from "../documents.js"; +import type { ReaderSourceFileClient } from "../source-files.js"; +import type { + CollectionFileDescriptor, + ConnectOutcome, + ConnectRequestOptions, + MdbaseFileListOptions, + MdbaseFileUploadOptions, +} from "@mdbase-dev/connect"; + +type StatTarget = Parameters[0]; + +const mediaTypes: Readonly> = { + pdf: "application/pdf", + epub: "application/epub+zip", + html: "text/html", + htm: "text/html", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", +}; + +function mediaTypeOf(path: string): string | undefined { + return mediaTypes[path.split(".").at(-1)?.toLowerCase() ?? ""]; +} + +/** + * A replica file view as a Connect file descriptor. The replica's revision is the + * content digest; file views carry no modification time. + */ +export function fileDescriptor(file: FileView): CollectionFileDescriptor { + const mediaType = mediaTypeOf(file.path); + return { + fileId: file.id, + path: file.path, + revision: file.digest, + contentDigest: file.digest as CollectionFileDescriptor["contentDigest"], + size: file.size, + ...(mediaType ? { mediaType } : {}), + mediaClass: file.media, + modifiedAt: "", + }; +} + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** + * Transfers are identified by UUID. Reader's stable retry keys are kept stable by + * deriving a UUID (version 8) from their SHA-256, so a retry resumes the same transfer. + */ +export async function transferUuid(key: string): Promise { + if (uuidPattern.test(key)) { + return key.toLowerCase(); + } + const digest = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key)), + ).slice(0, 16); + digest[6] = ((digest[6] ?? 0) & 0x0f) | 0x80; + digest[8] = ((digest[8] ?? 0) & 0x3f) | 0x80; + const hex = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +export type NextReaderFiles = ReaderFileClient & ReaderSourceFileClient & ReaderAssetClient; + +/** Reader's stat, list, upload and download over replica files. */ +export function nextReaderFiles(db: MdbaseClient): NextReaderFiles { + return { + stat: (target: StatTarget, options?: ConnectRequestOptions) => stat(db, target, options), + list: (options?: MdbaseFileListOptions) => list(db, options), + upload: (path: string, source: Blob, options?: MdbaseFileUploadOptions) => + upload(db, path, source, options).catch((error: unknown) => { + throw nextRepositoryError("upload a file", error); + }), + download: async (file: CollectionFileDescriptor, options?: { signal?: AbortSignal }) => { + const bytes = await db.files + .download(file.fileId, { ...(options?.signal ? { signal: options.signal } : {}) }) + .catch((error: unknown) => { + throw nextRepositoryError("download a file", error); + }); + return new Blob([bytes.slice().buffer], { + type: file.mediaType ?? mediaTypeOf(file.path) ?? "application/octet-stream", + }); + }, + }; +} + +async function stat( + db: MdbaseClient, + target: StatTarget, + options?: ConnectRequestOptions, +): Promise> { + try { + const file = await db.files.get( + target.path === undefined ? target.fileId : { path: target.path }, + options?.signal, + ); + return connectSuccess(fileDescriptor(file)); + } catch (error) { + if (isMdbaseError(error, "not_found") || isMdbaseError(error, "invalid_request")) { + // An unknown or malformed file ID is simply not a file here. + return connectSuccess(null); + } + return nextOutcome(() => Promise.reject(asMdbaseError(error))); + } +} + +async function* list( + db: MdbaseClient, + options: MdbaseFileListOptions = {}, +): AsyncGenerator { + for await (const file of db.files.list({ + ...(options.folder ? { folder: options.folder } : {}), + ...(options.pageSize ? { pageSize: options.pageSize } : {}), + ...(options.signal ? { signal: options.signal } : {}), + })) { + yield fileDescriptor(file); + } +} + +/** + * Uploads and returns the file as it now reads locally (pending until confirmed). + * A replace (`ifRevision`) targets the file currently at `path`. + */ +async function upload( + db: MdbaseClient, + path: string, + source: Blob, + options: MdbaseFileUploadOptions = {}, +): Promise { + const key = options.transferId ?? uuidv7(); + const transferId = await transferUuid(key); + const existing = options.ifRevision + ? await db.files.get({ path }, options.signal).catch(() => null) + : null; + const write = await db.files.upload(path, source, { + transferId, + // The same retry key resubmits the same mutation, so a retried commit is idempotent. + mutationId: await transferUuid(`${key}:commit`), + ...(existing ? { fileId: existing.id } : {}), + ...(options.ifRevision ? { ifRevision: options.ifRevision } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.onProgress + ? { + onProgress: (progress) => + options.onProgress?.({ + phase: "uploading", + transferredBytes: progress.done, + totalBytes: progress.total, + }), + } + : {}), + }); + if (write.state === "rejected" || write.state === "unknown") { + await write.confirmed; + } + return fileDescriptor(await db.files.get({ path }, options.signal)); +} diff --git a/packages/connect/src/next/index.ts b/packages/connect/src/next/index.ts new file mode 100644 index 0000000..4a068c4 --- /dev/null +++ b/packages/connect/src/next/index.ts @@ -0,0 +1,20 @@ +// The mdbase-next SDK backend (opt-in). Only this package imports `@mdbase-dev/sdk`. +export { readerSdkBackend, type ReaderSdkBackend } from "./backend.js"; +export { nextReaderClient, type NextReaderClientOptions } from "./client.js"; +export { nextReaderCollection } from "./collection.js"; +export { + ProposedHttpControlPlane, + nextGrantsStorageKey, + type ProposedControlPlaneOptions, + type ReaderNextControlPlane, + type ReaderNextGrant, +} from "./control-plane.js"; +export { + isWaitingForDevice, + nextErrorOf, + nextProblem, + nextProblemMessage, + waitingForDeviceMessage, +} from "./errors.js"; +export { nextReaderFiles, type NextReaderFiles } from "./files.js"; +export { ReaderNextApplicationSession, type ReaderNextSessionOptions } from "./session.js"; diff --git a/packages/connect/src/next/migration-target.test.ts b/packages/connect/src/next/migration-target.test.ts new file mode 100644 index 0000000..177daeb --- /dev/null +++ b/packages/connect/src/next/migration-target.test.ts @@ -0,0 +1,154 @@ +import { connect, mdbaseError, toPlain, type MdbaseClient } from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { NextMigrationTarget } from "./migration-target.js"; + +import type { Fields, MigrationRecord } from "@mdbase-reader/migration"; + +const clients: MdbaseClient[] = []; +const signal = (): AbortSignal => new AbortController().signal; +const record: MigrationRecord = { + id: "src_stable", + key: "zotero-item", + path: "sources/import.md", + fields: {}, + body: "Imported notes", +}; +const fields: Fields = { + id: record.id, + title: "A paper", + import: { namespace: "zotero:user:123", key: record.key }, +}; + +async function open(replica = new MemoryReplica({ confirmDelayMs: 0 })): Promise<{ + replica: MemoryReplica; + db: MdbaseClient; + target: NextMigrationTarget; +}> { + const db = await connect({ + app: { name: "reader-import-test", version: "0" }, + connector: replica.connector(), + reconnect: false, + }); + clients.push(db); + return { replica, db, target: new NextMigrationTarget(db) }; +} + +async function until(condition: () => boolean): Promise { + for (let i = 0; i < 100 && !condition(); i++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(condition()).toBe(true); +} + +afterEach(() => { + clients.splice(0).forEach((db) => db.close()); + vi.restoreAllMocks(); +}); + +describe("next Reader migration", () => { + it("waits for confirmation, then resumes with the same identity across sessions", async () => { + const replica = new MemoryReplica({ confirmDelayMs: null }); + const { db, target } = await open(replica); + let completed = false; + const operation = target.create(record, fields, "reader-source", signal()).then(() => { + completed = true; + }); + await until(() => replica.allRecords.length === 1); + expect(completed).toBe(false); + replica.confirmAll(); + await operation; + const id = replica.allRecords[0]?.id; + db.close(); + const reopened = await open(replica); + await reopened.target.create(record, fields, "reader-source", signal()); + expect(replica.allRecords).toHaveLength(1); + expect(replica.allRecords[0]?.id).toBe(id); + expect((await reopened.target.existing(signal())).get(record.id)).toEqual(fields); + }); + + it("reuses the original mutation when its confirmation response was lost", async () => { + const { db, target, replica } = await open(); + const submit = db.submit.bind(db); + vi.spyOn(db, "submit").mockImplementationOnce(async (...args) => { + await submit(...args); + throw mdbaseError("unavailable", "response lost"); + }); + await expect(target.create(record, fields, "reader-source", signal())).rejects.toMatchObject({ + code: "unavailable", + }); + await target.create(record, fields, "reader-source", signal()); + expect(replica.allRecords).toHaveLength(1); + }); + + it("canonicalizes field order but refuses changed payloads without overwriting", async () => { + const { target, replica } = await open(); + await target.create(record, fields, "reader-source", signal()); + await target.create( + record, + { import: fields["import"]!, title: "A paper", id: record.id }, + "reader-source", + signal(), + ); + await expect( + target.create(record, { ...fields, title: "Different paper" }, "reader-source", signal()), + ).rejects.toBeDefined(); + expect(replica.allRecords).toHaveLength(1); + expect(toPlain(replica.allRecords[0]!.frontmatter.get("title")!)).toBe("A paper"); + }); + + it("refuses collisions with existing paths and malformed provenance", async () => { + const { target, replica } = await open(); + replica.seed({ path: record.path, body: "User bytes" }); + await expect(target.create(record, fields, "reader-source", signal())).rejects.toMatchObject({ + code: "conflict", + reason: "path_taken", + }); + await expect( + target.create(record, { ...fields, import: null }, "reader-source", signal()), + ).rejects.toThrow("Invalid import identity"); + expect(replica.allRecords).toHaveLength(1); + expect(replica.allRecords[0]?.body).toBe("User bytes"); + }); + + it("stops on duplicate destination IDs and does not submit after cancellation", async () => { + const { target, replica, db } = await open(); + replica.seed({ path: "a.md", types: ["reader-source"], frontmatter: { id: record.id } }); + replica.seed({ path: "b.md", types: ["reader-annotation"], frontmatter: { id: record.id } }); + await expect(target.existing(signal())).rejects.toThrow("duplicate record IDs"); + const abort = new AbortController(); + abort.abort(); + const submit = vi.spyOn(db, "submit"); + await expect( + target.create(record, fields, "reader-source", abort.signal), + ).rejects.toBeDefined(); + expect(submit).not.toHaveBeenCalled(); + }); + + it("uploads confirmed files with collection-scoped stable transfer and file IDs", async () => { + const { target } = await open(); + const path = "files/reader/imports/paper.pdf"; + const bytes = new Blob(["PDF bytes"]); + const progress = vi.fn(); + const first = await target.upload( + path, + bytes, + "application/pdf", + "paper-key", + signal(), + progress, + ); + const again = await target.upload( + path, + bytes, + "application/pdf", + "paper-key", + signal(), + progress, + ); + expect(again).toEqual(first); + expect((await target.files(signal())).get(path)).toEqual(first); + expect(progress).toHaveBeenCalled(); + }); +}); diff --git a/packages/connect/src/next/migration-target.ts b/packages/connect/src/next/migration-target.ts new file mode 100644 index 0000000..1a59f61 --- /dev/null +++ b/packages/connect/src/next/migration-target.ts @@ -0,0 +1,175 @@ +import { encode, toPlain, toValue, type MdbaseClient } from "@mdbase-dev/sdk"; + +import { fileDescriptor, transferUuid } from "./files.js"; + +import type { + Fields, + ImportedFile, + Json, + MigrationRecord, + MigrationTarget, +} from "@mdbase-reader/migration"; + +// mdb-cbor/1 data maps preserve insertion order. Normalize importer-owned objects +// explicitly; arrays (including list fields) retain their semantic order. +function ordered(value: Json): Json { + if (Array.isArray(value)) { + return value.map(ordered); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, item]) => [key, ordered(item)]), + ); + } + return value; +} + +/** Browser imports use replica receipts, never the native legacy connector journal. */ +export class NextMigrationTarget implements MigrationTarget { + public readonly collectionId: string; + + constructor(private readonly db: MdbaseClient) { + this.collectionId = db.collection; + } + + async existing(signal: AbortSignal): Promise> { + const result = new Map(); + let cursor: string | undefined; + do { + const page = await this.db.query( + { + types: ["reader-source", "reader-annotation"], + limit: 1000, + ...(cursor ? { cursor } : {}), + }, + { effective: true }, + signal, + ); + for (const row of page.records) { + const persisted = toPlain(row.frontmatter) as Fields; + const effective = row.effective ? (toPlain(row.effective) as Fields) : persisted; + const id = effective["id"]; + if (typeof id !== "string") { + continue; + } + if (result.has(id)) { + throw new Error("The destination has duplicate record IDs. Import was stopped."); + } + // Provenance is a persisted extension, not a contract projection. + result.set(id, { ...effective, import: persisted["import"] ?? null }); + } + cursor = page.cursor; + } while (cursor); + return result; + } + + async files(signal: AbortSignal): Promise> { + const result = new Map(); + for await (const file of this.db.files.list({ folder: "files/reader/imports", signal })) { + result.set(file.path, fileDescriptor(file)); + } + return result; + } + + async upload( + path: string, + blob: Blob, + _mediaType: string, + key: string, + signal: AbortSignal, + progress: (bytes: number) => void, + ): Promise { + signal.throwIfAborted(); + const identity = JSON.stringify(["reader-import-file", this.collectionId, key]); + const fileId = await transferUuid(`${identity}:file`); + const write = await this.db.files.upload(path, blob, { + fileId, + transferId: await transferUuid(`${identity}:transfer`), + mutationId: await transferUuid(`${identity}:commit`), + signal, + onProgress: (p) => progress(p.done), + }); + // Do not let import verification mistake an optimistic upload for durable bytes. + await write.confirmed; + return fileDescriptor(await this.db.files.get(fileId, signal)); + } + + async create( + record: MigrationRecord, + fields: Fields, + type: "reader-source" | "reader-annotation", + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const provenance = fields["import"]; + if ( + !provenance || + typeof provenance !== "object" || + Array.isArray(provenance) || + typeof provenance["namespace"] !== "string" || + provenance["key"] !== record.key || + fields["id"] !== record.id + ) { + throw new Error("Invalid import identity or provenance."); + } + const identity = JSON.stringify([ + "reader-import-record", + this.collectionId, + provenance["namespace"], + record.id, + record.key, + ]); + const id = await transferUuid(identity); + const normalized = ordered(fields) as Fields; + // Normalized CBOR makes retries independent of object insertion order. A changed + // payload has a different mutation ID but the same record ID: it is refused as + // an existing record, never silently mistaken for the original write or updated. + const digest = new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + encode(toValue([type, record.path, normalized, record.body])).slice().buffer, + ), + ); + const fingerprint = [...digest].map((b) => b.toString(16).padStart(2, "0")).join(""); + // MigrationTarget commits one record at a time. The SDK expands mutationId to + // the singleton mutation_ids array when allowPartial is true; no fresh ID is + // generated on retries or after a page reload. + const [write] = await this.db.submit( + [ + { + kind: "create", + id, + type, + path: record.path, + frontmatter: new Map(Object.entries(normalized).map(([k, v]) => [k, toValue(v)])), + body: record.body, + }, + ], + { + allowPartial: true, + mutationId: await transferUuid(`${identity}:mutation:${fingerprint}`), + wait: "confirmed", + signal, + }, + ); + if (!write) { + throw new Error("The replica returned no import receipt."); + } + await write.confirmed; + const saved = await this.db.get(id, { effective: true }, signal); + const actual = toPlain(saved.frontmatter) as Fields; + const imported = actual["import"]; + if ( + actual["id"] !== record.id || + !imported || + typeof imported !== "object" || + Array.isArray(imported) || + imported["namespace"] !== provenance["namespace"] || + imported["key"] !== record.key + ) { + throw new Error("Confirmed import identity mismatch."); + } + } +} diff --git a/packages/connect/src/next/records.ts b/packages/connect/src/next/records.ts new file mode 100644 index 0000000..2f03b22 --- /dev/null +++ b/packages/connect/src/next/records.ts @@ -0,0 +1,102 @@ +// mdbase-next backend: record views and queries in the shapes Reader's repositories read. +import { toPlain, type Include, type Query, type RecordView } from "@mdbase-dev/sdk"; + +import { annotationContract, sourceContract } from "../contracts.js"; + +import type { + DataContractSelector, + JsonObject, + QueryInput, + QueryMetadataInput, + QueryRecord, + RecordDocument, +} from "@mdbase-dev/connect"; + +/** + * The new client API has no semantic contract views. Reader owns its types, so a + * contract selector reads the type that implements it in Reader's starter pack. + */ +const contractTypes: Readonly> = { + [sourceContract.id]: "reader-source", + [annotationContract.id]: "reader-annotation", +}; + +export function typeForContract(contract: DataContractSelector | undefined): string | undefined { + return contract ? (contract.type ?? contractTypes[contract.id]) : undefined; +} + +function plainObject(map: RecordView["frontmatter"] | undefined): JsonObject | undefined { + return map ? (toPlain(map) as JsonObject) : undefined; +} + +function fileFields(view: RecordView): RecordDocument["file"] { + const slash = view.path.lastIndexOf("/"); + const name = view.path.slice(slash + 1); + return { path: view.path, name, folder: slash < 0 ? "" : view.path.slice(0, slash) }; +} + +/** A replica record view as a Connect record document. */ +export function recordDocument(view: RecordView): RecordDocument { + const frontmatter = plainObject(view.frontmatter) ?? {}; + return { + path: view.path, + revision: view.revision, + types: [...view.types], + frontmatter, + effectiveFrontmatter: plainObject(view.effective) ?? frontmatter, + ...(view.body === undefined ? {} : { body: view.body }), + ...(view.document === undefined ? {} : { document: view.document }), + file: fileFields(view), + }; +} + +/** A replica record view as a Connect query row (always revision-qualified). */ +export function queryRecord(view: RecordView): QueryRecord { + const frontmatter = plainObject(view.frontmatter) ?? {}; + return { + path: view.path, + revision: view.revision, + types: [...view.types], + frontmatter, + effectiveFrontmatter: plainObject(view.effective) ?? frontmatter, + ...(view.body === undefined ? {} : { body: view.body }), + file: fileFields(view), + }; +} + +/** What to return with each record. Bodies only when the caller asked for them. */ +export function readInclude(options: { + readonly includeBody?: boolean; + readonly includeDocument?: boolean; + readonly frontmatterMode?: QueryInput["frontmatterMode"]; +}): Include { + return { + ...(options.includeBody ? { body: true } : {}), + ...(options.includeDocument ? { body: true, document: true } : {}), + ...(options.frontmatterMode === "persisted" ? {} : { effective: true }), + }; +} + +/** + * Connect query input as a spec 11 query. Projections, grouping and summaries have + * no equivalent in the replica's record query, so they are dropped here: callers + * that read projected `values` fall back to frontmatter. + */ +export function nextQuery(input: QueryInput | QueryMetadataInput): Query { + const type = typeForContract(input.contract); + const types = input.types ?? (type ? [type] : undefined); + return { + ...(types ? { types } : {}), + ...(input.where ? { where: input.where } : {}), + ...(input.orderBy?.length + ? { + order_by: input.orderBy.map(({ field, direction }) => ({ + field, + direction: direction ?? "asc", + })), + } + : {}), + ...(input.timezone ? { timezone: input.timezone } : {}), + ...(input.context ? { context: { this: { path: input.context.this.path } } } : {}), + }; +} diff --git a/packages/connect/src/next/session.test.ts b/packages/connect/src/next/session.test.ts new file mode 100644 index 0000000..55044d1 --- /dev/null +++ b/packages/connect/src/next/session.test.ts @@ -0,0 +1,128 @@ +import { + loadOrCreateClientKey, + mdbaseError, + memoryKeyStorage, + type Connector, +} from "@mdbase-dev/sdk"; +import { MemoryReplica } from "@mdbase-dev/sdk/testing"; +import { afterEach, describe, expect, it } from "vitest"; + +import { readerSdkBackend } from "./backend.js"; +import { routeFrom, type ReaderNextControlPlane, type ReaderNextGrant } from "./control-plane.js"; +import { waitingForDeviceMessage } from "./errors.js"; +import { ReaderNextApplicationSession, type ReaderNextSessionOptions } from "./session.js"; + +const sessions: ReaderNextApplicationSession[] = []; + +function controlPlane(grants: readonly ReaderNextGrant[]): ReaderNextControlPlane { + return { + grants: () => Promise.resolve(grants), + authorize: () => Promise.resolve(null), + resolveRoute: () => Promise.resolve(null), + }; +} + +function session( + grants: readonly ReaderNextGrant[], + connector: () => Connector, +): ReaderNextApplicationSession { + const options: ReaderNextSessionOptions = { + serverUrl: "https://connect.example", + app: { name: "dev.mdbase.reader", version: "test" }, + controlPlane: controlPlane(grants), + connector, + clientKey: () => loadOrCreateClientKey("reader-test", { storage: memoryKeyStorage() }), + }; + const created = new ReaderNextApplicationSession(options); + sessions.push(created); + return created; +} + +afterEach(() => { + for (const created of sessions.splice(0)) { + created.destroy(); + } +}); + +describe("mdbase-next Reader session", () => { + it("opens the only granted collection and serves Reader's repositories", async () => { + const replica = new MemoryReplica({ confirmDelayMs: null }); + replica.seed({ + path: "sources/one.md", + types: ["reader-source"], + frontmatter: { id: "src-1", title: "One" }, + }); + const grant = { collectionId: replica.collection, grant: "g-1", displayName: "Library" }; + const reader = session([grant], () => replica.connector()); + + const outcome = await reader.start(); + + expect(outcome.ok && outcome.value.status).toBe("ready"); + const opened = reader.connectedCollection(replica.collection); + expect(opened?.collectionName).toBe("Library"); + const page = await opened?.sources.list({ collectionId: opened.collectionId, limit: 10 }); + expect(page?.items.map((source) => source.title)).toEqual(["One"]); + expect(reader.connectedCollection("another-collection")).toBeNull(); + }); + + it("waits for one of the person's devices instead of failing", async () => { + const replica = new MemoryReplica(); + const grant = { collectionId: replica.collection, grant: "g-1", displayName: "Private" }; + let attempts = 0; + const offlineOnce: Connector = { + description: "offline once", + open: (hello) => { + attempts += 1; + return attempts === 1 + ? Promise.reject(mdbaseError("unavailable", "no device", "no_device_online")) + : replica.connector().open(hello); + }, + }; + const reader = session([grant], () => offlineOnce); + const snapshots: string[] = []; + reader.subscribe(() => { + const snapshot = reader.getSnapshot(); + snapshots.push(snapshot.status === "blocked" ? snapshot.problem.message : snapshot.status); + }); + + const outcome = await reader.start(); + + expect(outcome.ok && outcome.value.status).toBe("ready"); + expect(snapshots).toContain(waitingForDeviceMessage); + expect(reader.waitingForDevice).toBe(false); + }); + + it("asks the person to choose and reports the missing consent flow", async () => { + const reader = session([], () => new MemoryReplica().connector()); + + const started = await reader.start(); + const authorized = await reader.authorize(); + + expect(started.ok && started.value.status).toBe("unselected"); + expect(authorized.ok ? null : authorized.problem.code).toBe("unsupported_operation"); + await expect(reader.recoverPendingMutations()).resolves.toEqual([]); + }); +}); + +describe("mdbase-next configuration", () => { + it("keeps Connect unless the URL flag or build setting asks for next", () => { + expect(readerSdkBackend(null)).toBe("connect"); + expect(readerSdkBackend(null, "next")).toBe("next"); + expect(readerSdkBackend("next", "connect")).toBe("next"); + expect(readerSdkBackend("connect", "next")).toBe("connect"); + }); + + it("reads the proposed route response", () => { + const key = btoa(String.fromCharCode(...new Uint8Array(32).fill(7))) + .replaceAll("+", "-") + .replaceAll("/", "_"); + const route = routeFrom({ + targets: [{ url: "wss://relay.example/s/1", device: "device-1", noise_pk: key }], + }); + + expect(route).toMatchObject({ url: "wss://relay.example/s/1", targetDevice: "device-1" }); + expect(route?.noisePublicKey).toEqual(new Uint8Array(32).fill(7)); + expect(routeFrom({ targets: [] })).toBeNull(); + expect(() => routeFrom({})).toThrow(); + }); +}); diff --git a/packages/connect/src/next/session.ts b/packages/connect/src/next/session.ts new file mode 100644 index 0000000..3032c70 --- /dev/null +++ b/packages/connect/src/next/session.ts @@ -0,0 +1,337 @@ +// mdbase-next backend: Reader's application session over a relay connection. +import { connectFailure, connectProblem, connectSuccess } from "@mdbase-dev/connect/advanced"; +import { + connect, + isMdbaseError, + loadOrCreateClientKey, + relayConnector, + type ClientKey, + type Connector, + type MdbaseClient, +} from "@mdbase-dev/sdk"; + +import { nextReaderCollection } from "./collection.js"; +import { + ProposedHttpControlPlane, + type ReaderNextControlPlane, + type ReaderNextGrant, +} from "./control-plane.js"; +import { + asMdbaseError, + isWaitingForDevice, + nextProblem, + waitingForDeviceMessage, +} from "./errors.js"; + +import type { + ReaderConnectedCollection, + ReaderConnectSnapshot, + ReaderSession, +} from "../application-session.js"; +import type { ReaderPortableSession } from "../portable-application-session.js"; +import type { + ConnectFailure, + ConnectOutcome, + MdbaseConnectionInfo, + MdbaseEffectiveCapabilities, +} from "@mdbase-dev/connect"; + +export interface ReaderNextSessionOptions { + readonly serverUrl: string; + readonly app: { readonly name: string; readonly version: string }; + /** Defaults to the proposed HTTP control plane at `serverUrl`. */ + readonly controlPlane?: ReaderNextControlPlane; + /** Name of this app's client key in IndexedDB (a non-extractable WebCrypto key). */ + readonly keyName?: string; + /** Remembers the selected collection. */ + readonly storage?: Storage | null; + /** Test seam: how to reach a collection. Defaults to the relay. */ + readonly connector?: (grant: ReaderNextGrant, key: ClientKey) => Connector; + /** Test seam: the client key. Defaults to `loadOrCreateClientKey(keyName)`. */ + readonly clientKey?: () => Promise; +} + +const selectionKey = "mdbase-reader:next-selected"; + +type SessionProblem = Extract["problem"]; + +const capabilities: MdbaseEffectiveCapabilities = { + contractVersion: 2, + values: {}, + requiredAvailable: true, +}; + +function connectionInfo(grant: ReaderNextGrant): MdbaseConnectionInfo { + return { + collectionId: grant.collectionId, + displayName: grant.displayName, + operations: [], + scope: { contracts: [], access: "full_collection" }, + authority: { kind: "connector", durability: "computer" }, + route: "relay", + directAccess: "disabled", + }; +} + +/** + * Reader's session on the mdbase-next SDK (opt-in). It produces the same snapshots as + * the Connect session so the app's screens are unchanged. Waiting for a device of a + * private collection shows as `blocked` with {@link waitingForDeviceMessage}. + */ +export class ReaderNextApplicationSession implements ReaderSession, ReaderPortableSession { + readonly #options: ReaderNextSessionOptions; + readonly #controlPlane: ReaderNextControlPlane; + readonly #listeners = new Set<() => void>(); + #snapshot: ReaderConnectSnapshot = { status: "not_started", connections: [] }; + #grants: readonly ReaderNextGrant[] = []; + #key: ClientKey | null = null; + #db: MdbaseClient | null = null; + #opened: { readonly db: MdbaseClient; readonly collection: ReaderConnectedCollection } | null = + null; + #abort: AbortController | null = null; + #waiting = false; + + public constructor(options: ReaderNextSessionOptions) { + this.#options = options; + this.#controlPlane = + options.controlPlane ?? + new ProposedHttpControlPlane({ + serverUrl: options.serverUrl, + storage: options.storage ?? null, + }); + } + + /** True while a private collection waits for one of the person's devices. */ + public get waitingForDevice(): boolean { + return this.#waiting; + } + + public async start(): Promise> { + this.#set({ status: "starting", connections: this.#connections() }); + try { + this.#grants = await this.#controlPlane.grants((await this.#clientKey()).publicKey); + } catch (error) { + return this.#failed(nextProblem(asMdbaseError(error))); + } + const stored = this.#options.storage?.getItem(selectionKey); + const selected = + this.#grants.find((grant) => grant.collectionId === stored) ?? + (this.#grants.length === 1 ? this.#grants[0] : undefined); + if (!selected) { + this.#set({ status: "unselected", connections: this.#connections() }); + return connectSuccess(this.#snapshot); + } + return this.#open(selected); + } + + public destroy(): void { + this.#close(); + this.#set({ status: "destroyed", connections: this.#connections() }); + this.#listeners.clear(); + } + + public getSnapshot(): ReaderConnectSnapshot { + return this.#snapshot; + } + + public subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + public select(selectedCollectionId: string): ConnectOutcome { + const grant = this.#grants.find((known) => known.collectionId === selectedCollectionId); + if (!grant) { + return connectFailure( + connectProblem("unknown_collection", "Reader has no access to that collection."), + ); + } + this.#options.storage?.setItem(selectionKey, grant.collectionId); + void this.#open(grant); + return connectSuccess(null); + } + + public async authorize(): Promise> { + try { + const grant = await this.#controlPlane.authorize((await this.#clientKey()).publicKey); + if (!grant) { + return connectFailure( + connectProblem( + "unsupported_operation", + "Connecting a collection to Reader on mdbase-next needs the control plane’s consent flow, which is not available yet.", + ), + ); + } + this.#grants = [...this.#grants.filter((g) => g.collectionId !== grant.collectionId), grant]; + return this.select(grant.collectionId); + } catch (error) { + return connectFailure(nextProblem(asMdbaseError(error))); + } + } + + public clearSelection(): void { + this.#options.storage?.removeItem(selectionKey); + this.#close(); + this.#set({ status: "unselected", connections: this.#connections() }); + } + + /** Nothing to recover: the SDK resubmits pending writes by mutation ID itself. */ + public recoverPendingMutations(): Promise[]> { + return Promise.resolve([]); + } + + /** STUB: type-pack setup is not part of the replica client API yet. */ + public applyCollectionSetup(): Promise> { + return Promise.resolve(connectSuccess(this.#snapshot)); + } + + public connectedCollection(expectedCollectionId?: string): ReaderConnectedCollection | null { + const snapshot = this.#snapshot; + const db = this.#db; + if ( + snapshot.status !== "ready" || + !db || + (expectedCollectionId !== undefined && snapshot.collectionId !== expectedCollectionId) + ) { + return null; + } + if (this.#opened?.db !== db) { + this.#opened = { + db, + collection: nextReaderCollection(db, this.#selectedGrant(snapshot.collectionId)), + }; + } + return this.#opened.collection; + } + + async #open(grant: ReaderNextGrant): Promise> { + this.#close(); + const abort = new AbortController(); + this.#abort = abort; + const context = this.#context(grant); + this.#set({ status: "checking_setup", ...context }); + try { + const key = await this.#clientKey(); + const db = await connect({ + app: { name: this.#options.app.name, version: this.#options.app.version }, + connector: this.#connector(grant, key), + waitForDevice: true, + signal: abort.signal, + onWaiting: () => { + this.#waiting = true; + this.#set({ status: "blocked", ...context, problem: waitingProblem() }); + }, + }); + if (abort.signal.aborted) { + db.close(); + return connectSuccess(this.#snapshot); + } + this.#waiting = false; + this.#db = db; + db.onLink((state, why) => this.#linkChanged(db, grant, state, why)); + this.#set({ status: "ready", verification: "verified", ...context }); + return connectSuccess(this.#snapshot); + } catch (error) { + if (abort.signal.aborted) { + return connectSuccess(this.#snapshot); + } + this.#waiting = false; + return this.#failed(nextProblem(asMdbaseError(error))); + } + } + + #linkChanged( + db: MdbaseClient, + grant: ReaderNextGrant, + state: MdbaseClient["link"], + why: MdbaseClient["linkProblem"], + ): void { + if (db !== this.#db) { + return; + } + // A reconnecting link keeps the workspace open: reads wait and writes stay pending. + this.#waiting = state === "reconnecting" && isWaitingForDevice(why); + if (state === "closed" && why && isMdbaseError(why)) { + const problem = nextProblem(why); + this.#set({ + status: "blocked", + ...this.#context(grant), + problem: { code: problem.code, message: problem.message, recovery: problem.recovery }, + }); + } + } + + #connector(grant: ReaderNextGrant, key: ClientKey): Connector { + if (this.#options.connector) { + return this.#options.connector(grant, key); + } + return relayConnector({ + collection: grant.collectionId, + grant: grant.grant, + staticKey: key, + resolveRoute: () => this.#controlPlane.resolveRoute(grant.collectionId), + }); + } + + async #clientKey(): Promise { + this.#key ??= await (this.#options.clientKey?.() ?? + loadOrCreateClientKey(this.#options.keyName ?? "mdbase-reader")); + return this.#key; + } + + #close(): void { + this.#abort?.abort(); + this.#abort = null; + this.#db?.close(); + this.#db = null; + this.#waiting = false; + } + + #selectedGrant(id: string): ReaderNextGrant { + return ( + this.#grants.find((grant) => grant.collectionId === id) ?? { + collectionId: id, + grant: "", + displayName: id, + } + ); + } + + #context(grant: ReaderNextGrant): { + readonly collectionId: string; + readonly info: MdbaseConnectionInfo; + readonly capabilities: MdbaseEffectiveCapabilities; + readonly connections: MdbaseConnectionInfo[]; + } { + return { + collectionId: grant.collectionId, + info: connectionInfo(grant), + capabilities, + connections: this.#connections(), + }; + } + + #connections(): MdbaseConnectionInfo[] { + return this.#grants.map(connectionInfo); + } + + #failed(problem: ConnectFailure["problem"]): ConnectOutcome { + this.#set({ + status: "start_failed", + problem: problem as SessionProblem, + connections: this.#connections(), + }); + return connectFailure(problem); + } + + #set(snapshot: ReaderConnectSnapshot): void { + this.#snapshot = snapshot; + for (const listener of [...this.#listeners]) { + listener(); + } + } +} + +function waitingProblem(): { code: string; message: string; recovery: string } { + return { code: "no_device_online", message: waitingForDeviceMessage, recovery: "retry" }; +} diff --git a/packages/connect/src/portable-application-session.ts b/packages/connect/src/portable-application-session.ts index 32cf13d..3cd469b 100644 --- a/packages/connect/src/portable-application-session.ts +++ b/packages/connect/src/portable-application-session.ts @@ -18,6 +18,7 @@ import { connectedReaderCollection, type ReaderConnectedCollection, type ReaderConnectSnapshot, + type ReaderSession, } from "./application-session.js"; export interface ReaderPortableApplicationSessionOptions { @@ -30,8 +31,18 @@ export interface ReaderPortableApplicationSessionOptions { readonly timeouts?: MdbaseConnectTimeouts; } +/** What the extension needs from a portable session, whichever SDK backs it. */ +export interface ReaderPortableSession extends Omit { + clearSelection(): void; + recoverPendingMutations(): Promise[]>; + authorize( + target: "choose" | "selected", + options?: MdbaseAuthorizeOptions, + ): Promise>; +} + /** Extension/download-friendly Reader session using the SDK's device-code flow. */ -export class ReaderPortableApplicationSession { +export class ReaderPortableApplicationSession implements ReaderPortableSession { readonly #session; public constructor(options: ReaderPortableApplicationSessionOptions) { diff --git a/packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz b/packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz new file mode 100644 index 0000000..9221cb9 Binary files /dev/null and b/packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e10d11d..44cb99d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: '@mdbase-dev/connect': specifier: 0.1.0-beta.125 version: 0.1.0-beta.125 + '@mdbase-dev/sdk': + specifier: file:vendor/mdbase-dev-sdk-c573c96.tgz + version: file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz '@mdbase-reader/core': specifier: workspace:* version: link:../core @@ -1135,6 +1138,11 @@ packages: '@mdbase-dev/connect@0.1.0-beta.125': resolution: {integrity: sha512-vEH4NQfu3dBQgMw0YQxG7LDtOOLxDyxET4DQhrWuHqtgr9qoch/H0QLzel9tFSqkyH+QWxuvW3Bet6ae1+qVyQ==} + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz': + resolution: {integrity: sha512-AnSmYSVUQJpXofFuN5QuZX0XHL1y/LBhkkechQhvvqiFixs+xA/xxu6rjxHOt7atx67fUH8VvvRjwelw47eQJg==, tarball: file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz} + version: 0.0.0 + engines: {node: '>=20'} + '@mdbase-dev/ui@0.1.0-beta.125': resolution: {integrity: sha512-K2UAAzwW4yuPJxx65LofVDQXKLgs4lOgrAB61YMuiNwV/jOc1yuNhGugy7Fx++VwxBx5bZyG+Pk7tMQlOat+tg==} peerDependencies: @@ -1155,6 +1163,18 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.4.0': + resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} @@ -4299,6 +4319,12 @@ snapshots: dependencies: '@mdbase-dev/connect-protocol': 0.1.0-beta.125 + '@mdbase-dev/sdk@file:packages/connect/vendor/mdbase-dev-sdk-c573c96.tgz': + dependencies: + '@noble/ciphers': 2.4.0 + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@mdbase-dev/ui@0.1.0-beta.125(@codemirror/view@6.43.8)(react@19.2.8)': dependencies: '@fontsource/atkinson-hyperlegible': 5.3.0 @@ -4316,6 +4342,14 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/ciphers@2.4.0': {} + + '@noble/curves@2.4.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@noble/hashes@2.4.0': {} + '@nodable/entities@3.0.0': {} '@oxc-project/types@0.149.0': {}