From 2bf1cff8adae4ad012840f84321520b2728a8bef Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 4 Nov 2026 20:51:15 +0800 Subject: [PATCH 1/3] =?UTF-8?q?ref(core):=20=E5=88=86=E7=A6=BB=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20SDK=20=E4=B8=8E=E5=BA=94=E7=94=A8=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/core-app-separation.md | 7 + ARCHITECTURE.md | 4 + FILESYSTEM.md | 3 +- package.json | 5 +- packages/core/AGENTS.md | 4 + packages/core/README.md | 25 ++ packages/core/package.json | 17 +- packages/core/src/auth/sign.ts | 20 ++ packages/core/src/auth/store.ts | 21 +- packages/core/src/base/client.ts | 175 +++++++++++++ packages/core/src/base/db-api.ts | 246 ++++-------------- packages/core/src/info-base/storages/blob.ts | 87 +++++++ .../core/src/info-base/storages/postgresql.ts | 77 ++---- packages/core/src/peer/contracts.ts | 2 +- packages/core/src/peer/runtime.ts | 17 +- packages/core/src/sdk.ts | 12 + packages/core/tsdown.config.ts | 26 +- scripts/tests/core-independent.test.mjs | 111 ++++++++ 18 files changed, 554 insertions(+), 305 deletions(-) create mode 100644 .changeset/core-app-separation.md create mode 100644 packages/core/README.md create mode 100644 packages/core/src/auth/sign.ts create mode 100644 packages/core/src/base/client.ts create mode 100644 packages/core/src/info-base/storages/blob.ts create mode 100644 packages/core/src/sdk.ts create mode 100644 scripts/tests/core-independent.test.mjs diff --git a/.changeset/core-app-separation.md b/.changeset/core-app-separation.md new file mode 100644 index 00000000..6f1de44b --- /dev/null +++ b/.changeset/core-app-separation.md @@ -0,0 +1,7 @@ +--- +'@inkcre/core': patch +'@inkcre/client-web': patch +'@inkcre/client-webext': patch +--- + +在既有 0.4 兼容线新增无 Vue/Pinia 依赖的 `@inkcre/core/sdk` 入口,提供显式连接的数据访问、字节存储、认证与协议合同;现有应用入口保留兼容,并复用相同传输和存储实现。Web/WebExt 随其内嵌 Core 更新,不要求 MF 分发迁移 Host ABI。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 18daba7e..87145a21 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -68,3 +68,7 @@ delivery. Validation checks upload no deployable SPA, MF, or Registry handoff ar Executable configuration, scripts, tests, and workflows remain authoritative for exact commands and wire values. + +## Core 与 App 消费边界 + +`@inkcre/core/sdk` 是不加载 UI 的独立消费入口,接受显式连接,提供类型化数据库操作、字节存储、认证和协议合同。根入口是既有 Vue/MF 应用的兼容入口,保留配置选择、导航和视图状态;其 DBAPIClient 与 PostgreSQLBinaryStorage 复用独立 SDK 的实现。详细支持范围与资源所有权见 [Core 包说明](packages/core/README.md)。两入口的运行代码和声明分别构建,独立消费者无需安装 Vue/Pinia,也不继承 App 的默认连接。 diff --git a/FILESYSTEM.md b/FILESYSTEM.md index e6ed151c..d4a53821 100644 --- a/FILESYSTEM.md +++ b/FILESYSTEM.md @@ -22,7 +22,8 @@ - `apps/client-web/src/core.ts` - Core registration and initialization. - `apps/client-web/src/router.ts` - Web route authority. - `apps/client-webext/entrypoints/` - WXT-discovered extension surfaces. -- `packages/core/src/index.ts` - Shared package exports. +- `packages/core/src/sdk.ts` - Independent SDK exports with explicit connections. +- `packages/core/src/index.ts` - Published Vue/MF compatibility exports. - `extensions/*/src/index.ts` - Host-consumed Extension exports. - `extensions/*/src/main.ts` - Local producer playgrounds. - `pyproject.toml` / `pdm.lock` - Frozen repository-level Python preview tooling. diff --git a/package.json b/package.json index 44ca0e25..6197e3b1 100644 --- a/package.json +++ b/package.json @@ -38,8 +38,9 @@ "verify:twitter": "playwright test --config scripts/tests/twitter-acceptance.config.ts", "verify:core-release": "node --test scripts/tests/core-release.test.mjs", "build": "pnpm check:workspace && pnpm -r run build && pnpm check:package", - "check": "pnpm format:check && pnpm lint && pnpm type-check && pnpm build", - "ci": "pnpm check && pnpm test:e2e" + "check": "pnpm format:check && pnpm lint && pnpm type-check && pnpm build && pnpm check:sdk", + "ci": "pnpm check && pnpm test:e2e", + "check:sdk": "pnpm --filter @inkcre/core build && node --test scripts/tests/core-independent.test.mjs" }, "devDependencies": { "@changesets/cli": "3.0.0", diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 8feec6ec..4b76c7b4 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -27,3 +27,7 @@ Shared models, Peer protocols, storage/resolver mechanics, and native Extension - `pnpm lint:type-aware` - `pnpm type-check` - `pnpm check` + +- `src/sdk.ts` is the independent consumer boundary; neither its runtime nor its declarations may import Vue, Pinia, application stores, or the root compatibility barrel. +- Keep explicit transport/storage logic in the SDK and reuse it from the published Vue entry. Do not duplicate protocol behavior when preserving compatibility. +- The packed independent-consumer check is `node --test scripts/tests/core-independent.test.mjs` after building Core. diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000..789da5ba --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,25 @@ +# @inkcre/core + +独立程序使用 `@inkcre/core/sdk`。这一入口提供明确绑定连接的 `DatabaseClient`、类型化关系与 RPC 合同、JWT 签名、PostgreSQL 字节存储和 Peer 协议/Resolver 身份。支持有标准 Fetch API 的 Node.js 与浏览器环境,通过已有 PostgREST 数据库协议访问共享状态。 + +```ts +import { DatabaseClient, signDatabaseToken } from '@inkcre/core/sdk' + +const connection = { + baseUrl: 'https://database.example/rest/v1', + tokenProvider: () => signDatabaseToken(secret), +} +const blocks = new DatabaseClient('blocks', undefined, connection) +const { data, error } = await blocks.from().select().eq('id', blockId).maybeSingle() +if (error) throw error +``` + +连接由调用方提供;客户端捕获 URL、认证函数与 Fetch 引用,不读取应用的当前连接。认证函数应绑定自己的凭据来源。构造不发请求、不注册 Peer、不启动 timer;客户端不拥有连接池或后台任务,无需关闭,也不关闭借用的 Fetch 资源。查询的取消使用 PostgREST builder 的 `abortSignal`。创建多个客户端不会共享配置或认证状态。 + +`PostgreSQLBlobStorage.forConnection(connection)` 提供原有 bytea RPC 和稳定 UUID pointer 的读、创建、更新和删除。返回的字节与 pointer 语义与旧入口一致。它和旧 `PostgreSQLBinaryStorage` 复用同一实现。普通关系访问、写入序列化与错误处理同样只有一份实现;Web/WebExt 的 `DBAPIClient` 在此之上提供 Vue 应用选中配置的兼容行为。 + +已发布的 `@inkcre/core` 根入口继续保留 Vue/Pinia 模型、应用配置 store、导航、Renderer 与 Extension Host 合同,供现有 App 和 MF 分发兼容使用。这些能力不会经 `/sdk` 重导出。根入口使用者仍需安装 Vue、Pinia、VueUse;独立 SDK 使用者不需要,包的框架 peer dependency 为 optional。两入口采用分开的构建与声明依赖图,避免只隔离 JavaScript 而留下类型声明的 UI 依赖。 + +这是按实际用途切出的消费边界,不是新的平台适配包。当前独立入口不承诺覆盖根入口所有高层领域 facade 或 Host UI;需要既有 Vue 生命周期的功能继续从兼容入口消费。Core SDK 版本仍由包自身发布,数据库协议与 MF Host ABI 仍按各自原合同判断。 + +`node --test scripts/tests/core-independent.test.mjs` 从本仓根检查真实 npm tarball:仓库外导入、无 UI 声明编译、并发目标隔离、序列化和二进制请求。测试使用受控传输,不连接真实数据库;构建与本地检查不能替代跨语言端到端验收。 diff --git a/packages/core/package.json b/packages/core/package.json index 2a65955f..d9a1daad 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,10 +12,14 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./sdk": { + "types": "./dist/sdk/sdk.d.ts", + "import": "./dist/sdk/sdk.js" } }, "scripts": { - "build": "tsdown", + "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsdown", "dev": "tsdown --watch", "type-check": "tsc --noEmit" }, @@ -50,5 +54,16 @@ "@vueuse/core": "^14.0.0", "pinia": "^3.0.0", "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@vueuse/core": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vue": { + "optional": true + } } } diff --git a/packages/core/src/auth/sign.ts b/packages/core/src/auth/sign.ts new file mode 100644 index 00000000..37460f32 --- /dev/null +++ b/packages/core/src/auth/sign.ts @@ -0,0 +1,20 @@ +import { SignJWT } from 'jose' +import { peerJwtContract } from '../database' +import { DEFAULT_PEER_JWT_LIFETIME_SECONDS } from './defaults' + +const JWT_CLOCK_SKEW_SECONDS = 5 + +export async function signDatabaseToken(jwtSecret: string): Promise { + if (!jwtSecret) throw new Error('JWT_SECRET not configured') + + const secret = new TextEncoder().encode(jwtSecret) + // A Peer and its PostgREST boundary may not share an exact wall clock. + const issuedAt = Math.floor(Date.now() / 1000) - JWT_CLOCK_SKEW_SECONDS + return new SignJWT({ role: peerJwtContract.role }) + .setProtectedHeader({ alg: peerJwtContract.algorithm }) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + DEFAULT_PEER_JWT_LIFETIME_SECONDS) + .setIssuer(peerJwtContract.issuer) + .setAudience(peerJwtContract.audience) + .sign(secret) +} diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 6ed1b2d1..672c150e 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -1,25 +1,8 @@ import { ref, watch } from 'vue' -import { SignJWT } from 'jose' import { configStore } from '../config' -import { peerJwtContract } from '../database' -import { DEFAULT_PEER_JWT_LIFETIME_SECONDS } from './defaults' -const JWT_CLOCK_SKEW_SECONDS = 5 - -export async function signDatabaseToken(jwtSecret: string): Promise { - if (!jwtSecret) throw new Error('JWT_SECRET not configured') - - const secret = new TextEncoder().encode(jwtSecret) - // A Peer and its PostgREST boundary may not share an exact wall clock. - const issuedAt = Math.floor(Date.now() / 1000) - JWT_CLOCK_SKEW_SECONDS - return new SignJWT({ role: peerJwtContract.role }) - .setProtectedHeader({ alg: peerJwtContract.algorithm }) - .setIssuedAt(issuedAt) - .setExpirationTime(issuedAt + DEFAULT_PEER_JWT_LIFETIME_SECONDS) - .setIssuer(peerJwtContract.issuer) - .setAudience(peerJwtContract.audience) - .sign(secret) -} +import { signDatabaseToken } from './sign' +export { signDatabaseToken } from './sign' /** * Create an auth store instance. diff --git a/packages/core/src/base/client.ts b/packages/core/src/base/client.ts new file mode 100644 index 00000000..360c9e83 --- /dev/null +++ b/packages/core/src/base/client.ts @@ -0,0 +1,175 @@ +import { PostgrestClient } from '@supabase/postgrest-js' +import type { Database, InkcreSchema, RelationName, RelationRow } from '../database' + +type RelationInsert = InkcreSchema['Tables'][Relation]['Insert'] +type RelationUpdate = InkcreSchema['Tables'][Relation]['Update'] +export type DatabaseTokenProvider = () => Promise + +export interface DatabaseConnection { + readonly baseUrl: string + readonly tokenProvider: DatabaseTokenProvider + /** Borrowed transport; the SDK does not close caller resources. */ + readonly fetch?: typeof globalThis.fetch +} + +function encodeTransportValue(value: unknown): Value { + const encoded = JSON.stringify(value) + if (encoded === undefined) { + throw new TypeError('Database write value must be JSON serializable.') + } + return JSON.parse(encoded) as Value +} + +function normalizePostgrestBaseUrl(value: string): string { + return value.replace(/\/+$/, '') +} + +/** + * API Error class for handling API request errors + */ +export class APIError extends Error { + constructor( + message: string, + public status: number, + public response?: any + ) { + super(message) + this.name = 'APIError' + } +} + +export type RawPostgrestRequestOptions = RequestInit & { + schema?: 'inkcre' + baseUrl: string + tokenProvider: DatabaseTokenProvider + fetch?: typeof globalThis.fetch +} + +/** + * Execute a PostgREST request whose body or response is not JSON. + * + * The regular PostgREST client remains the owner of relational/JSON queries; + * this seam exists for admitted media contracts such as raw bytea RPCs. + */ +export async function rawPostgrestFetch( + path: string, + options: RawPostgrestRequestOptions +): Promise { + const { + schema = 'inkcre', + baseUrl, + tokenProvider, + fetch: fetcher = globalThis.fetch, + ...init + } = options + const configuredBaseUrl = baseUrl + if (!configuredBaseUrl) { + throw new APIError('PostgREST URL is not configured.', 0) + } + + const normalizedBaseUrl = configuredBaseUrl.endsWith('/') + ? configuredBaseUrl + : `${configuredBaseUrl}/` + const url = new URL(path.replace(/^\//, ''), normalizedBaseUrl) + const token = await tokenProvider() + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${token}`) + headers.set('Accept-Profile', schema) + headers.set('Content-Profile', schema) + + const response = await fetcher(url, { ...init, headers }) + if (!response.ok) { + const errorResponse = response.clone() + let details: unknown + try { + details = await errorResponse.json() + } catch { + details = await response.clone().text() + } + throw new APIError( + `PostgREST request failed with status ${response.status}.`, + response.status, + details + ) + } + return response +} + +/** Typed PostgREST operations bound to a caller-owned connection. */ +export class DatabaseClient< + Relation extends RelationName, + DT = RelationRow, +> extends PostgrestClient { + constructor( + protected relation: Relation, + protected defSchema: { parse(input: unknown): DT } | undefined, + connection: DatabaseConnection + ) { + const { baseUrl, tokenProvider, fetch: fetcher = globalThis.fetch } = connection + const url = new URL(baseUrl) + if (!['https:', 'http:'].includes(url.protocol)) { + throw new TypeError('PostgREST URL must use HTTP(S).') + } + super(normalizePostgrestBaseUrl(url.href), { + schema: 'inkcre', + fetch: async (input, init) => { + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${await tokenProvider()}`) + return fetcher(input, { ...init, headers }) + }, + }) + } + + /** + * Get a query builder for the configured relation + * (PostgREST method) + */ + public from() { + return super.from(this.relation) + } + + public insert(value: unknown) { + type Insert = RelationInsert + const query = super.from(this.relation) + const insert = query.insert.bind(query) as unknown as ( + transportValue: Insert | Insert[] + ) => ReturnType + return insert(encodeTransportValue(value)) + } + + public update(value: unknown) { + type Update = RelationUpdate + const query = super.from(this.relation) + const update = query.update.bind(query) as unknown as ( + transportValue: Update + ) => ReturnType + return update(encodeTransportValue(value)) + } + + public upsert(value: unknown) { + type Insert = RelationInsert + const query = super.from(this.relation) + const upsert = query.upsert.bind(query) as unknown as ( + transportValue: Insert | Insert[] + ) => ReturnType + return upsert(encodeTransportValue(value)) + } + + /** + * Extract the first item from a query response + * (PostgREST helper method) + * + * @param res - Response from a PostgREST query + * @param schema - Optional schema for parsing the item + * @returns Parsed first item + */ + public first(res: { data: unknown[] | null }, schema?: { parse(input: unknown): T }): T { + const item = res.data?.[0] + if (item === undefined) { + throw new APIError(`Relation ${this.relation} returned no rows.`, 404) + } + if (schema) return schema.parse(item) + if (this.defSchema) return this.defSchema.parse(item) as unknown as T + return item as T + } +} diff --git a/packages/core/src/base/db-api.ts b/packages/core/src/base/db-api.ts index ce86b2e7..6de8d2ec 100644 --- a/packages/core/src/base/db-api.ts +++ b/packages/core/src/base/db-api.ts @@ -1,223 +1,77 @@ -import { observeOperation, SpanKind, SpanStatusCode } from '../obsrv/telemetry' -import { configStore as sharedConfigStore } from '../config' -import { authStore } from '../auth' -import { PostgrestClient } from '@supabase/postgrest-js' import { watch } from 'vue' -import type { Database, InkcreSchema, RelationName, RelationRow } from '../database' - -type RelationInsert = InkcreSchema['Tables'][Relation]['Insert'] -type RelationUpdate = InkcreSchema['Tables'][Relation]['Update'] -export type DatabaseTokenProvider = () => Promise - -function encodeTransportValue(value: unknown): Value { - const encoded = JSON.stringify(value) - if (encoded === undefined) { - throw new TypeError('Database write value must be JSON serializable.') - } - return JSON.parse(encoded) as Value -} - -function normalizePostgrestBaseUrl(value: string): string { - return value.replace(/\/+$/, '') -} - -/** - * API Error class for handling API request errors - */ -export class APIError extends Error { - constructor( - message: string, - public status: number, - public response?: any - ) { - super(message) - this.name = 'APIError' - } -} - -export type RawPostgrestRequestOptions = RequestInit & { - schema?: 'inkcre' - baseUrl?: string - tokenProvider?: DatabaseTokenProvider -} - -/** - * Execute a PostgREST request whose body or response is not JSON. - * - * The regular PostgREST client remains the owner of relational/JSON queries; - * this seam exists for admitted media contracts such as raw bytea RPCs. - */ -export async function rawPostgrestFetch( - path: string, - options: RawPostgrestRequestOptions = {} -): Promise { - const { - schema = 'inkcre', - baseUrl, - tokenProvider = () => authStore.getToken(), - ...init - } = options - const configuredBaseUrl = baseUrl ?? sharedConfigStore.metaConfig.INKCRE_PGREST_URL - if (!configuredBaseUrl) { - throw new APIError('PostgREST URL is not configured.', 0) - } - - const normalizedBaseUrl = configuredBaseUrl.endsWith('/') - ? configuredBaseUrl - : `${configuredBaseUrl}/` - const url = new URL(path.replace(/^\//, ''), normalizedBaseUrl) - const token = await tokenProvider() - const headers = new Headers(init.headers) - headers.set('Authorization', `Bearer ${token}`) - headers.set('Accept-Profile', schema) - headers.set('Content-Profile', schema) - - const response = await observeOperation( +import { configStore } from '../config' +import { authStore } from '../auth' +import { observeOperation, SpanKind, SpanStatusCode } from '../obsrv/telemetry' +import type { RelationName, RelationRow } from '../database' +import { + DatabaseClient, + rawPostgrestFetch as fetchRaw, + type DatabaseTokenProvider, + type RawPostgrestRequestOptions as ExplicitRequestOptions, +} from './client' + +export { APIError } from './client' +export type { DatabaseTokenProvider } from './client' + +const observedFetch: typeof fetch = (input, init) => + observeOperation( 'postgrest.http', async (_active, span, outcome) => { - const result = await fetch(url, { ...init, headers }) - span?.setAttribute('http.response.status_code', result.status) - if (result.status >= 400) { + const response = await fetch(input, init) + span?.setAttribute('http.response.status_code', response.status) + if (response.status >= 400) { outcome.outcome = 'error' span?.setStatus({ code: SpanStatusCode.ERROR }) } - return result + return response }, { kind: SpanKind.CLIENT } ) - if (!response.ok) { - const errorResponse = response.clone() - let details: unknown - try { - details = await errorResponse.json() - } catch { - details = await response.clone().text() - } - throw new APIError( - `PostgREST request failed with status ${response.status}.`, - response.status, - details - ) - } - return response + +export type RawPostgrestRequestOptions = Omit< + ExplicitRequestOptions, + 'baseUrl' | 'tokenProvider' +> & { + baseUrl?: string + tokenProvider?: DatabaseTokenProvider } -/** - * Database API Client - Pure PostgREST wrapper for database operations. - * - * This client provides: - * - PostgREST methods (`.from()`, `.rpc()`) for database operations - * - Automatic authentication via authStore - * - Reactive config updates via configStore - * - * Business HTTP execution belongs to its domain facade and PeerManager, not this database seam. - * - * @template DT - Data type for the records in the specified relation - */ +/** Compatibility with the published Vue entry; new SDK callers supply a connection. */ +export function rawPostgrestFetch(path: string, options: RawPostgrestRequestOptions = {}) { + return fetchRaw(path, { + ...options, + baseUrl: options.baseUrl ?? configStore.metaConfig.INKCRE_PGREST_URL, + tokenProvider: options.tokenProvider ?? (() => authStore.getToken()), + fetch: options.fetch ?? observedFetch, + }) +} + +/** Application-selected default retained for published Vue consumers. */ export class DBAPIClient< Relation extends RelationName, DT = RelationRow, -> extends PostgrestClient { - protected configStore = sharedConfigStore - - /** - * @param relation - Database relation (table) name for PostgREST operations - * @param defSchema - Optional default schema for parsing responses - * @param schemaName - PostgreSQL schema name (default: "public") - * @param baseUrl - Base URL of the PostgREST API (default: from CONFIG) - */ +> extends DatabaseClient { constructor( - protected relation: Relation, - protected defSchema?: { parse(input: unknown): DT }, + relation: Relation, + defSchema?: { parse(input: unknown): DT }, public schemaName: 'inkcre' = 'inkcre', - baseUrl: string = '', + baseUrl = '', tokenProvider: DatabaseTokenProvider = () => authStore.getToken() ) { - super(normalizePostgrestBaseUrl(baseUrl), { - schema: schemaName, - // Custom fetch to dynamically inject auth token on each request - fetch: async (input, init) => { - const token = await tokenProvider() - const headers = new Headers(init?.headers) - headers.set('Authorization', `Bearer ${token}`) - return observeOperation( - 'postgrest.http', - async (_active, span, outcome) => { - const response = await fetch(input, { ...init, headers }) - span?.setAttribute('http.response.status_code', response.status) - if (response.status >= 400) { - outcome.outcome = 'error' - span?.setStatus({ code: SpanStatusCode.ERROR }) - } - return response - }, - { kind: SpanKind.CLIENT } - ) - }, + // Unconfigured UI may construct models before the connection form is completed. + super(relation, defSchema, { + baseUrl: baseUrl || configStore.metaConfig.INKCRE_PGREST_URL || 'http://unconfigured.invalid', + tokenProvider, + fetch: observedFetch, }) - - // If no baseUrl provided, use configStore and watch for changes if (!baseUrl) { watch( - () => this.configStore.metaConfig.INKCRE_PGREST_URL, - (newVal) => { - this.url = normalizePostgrestBaseUrl(newVal) + () => configStore.metaConfig.INKCRE_PGREST_URL, + (value) => { + this.url = value.replace(/\/+$/, '') }, { immediate: true, flush: 'sync' } ) } } - - /** - * Get a query builder for the configured relation - * (PostgREST method) - */ - public from() { - return super.from(this.relation) - } - - public insert(value: unknown) { - type Insert = RelationInsert - const query = super.from(this.relation) - const insert = query.insert.bind(query) as unknown as ( - transportValue: Insert | Insert[] - ) => ReturnType - return insert(encodeTransportValue(value)) - } - - public update(value: unknown) { - type Update = RelationUpdate - const query = super.from(this.relation) - const update = query.update.bind(query) as unknown as ( - transportValue: Update - ) => ReturnType - return update(encodeTransportValue(value)) - } - - public upsert(value: unknown) { - type Insert = RelationInsert - const query = super.from(this.relation) - const upsert = query.upsert.bind(query) as unknown as ( - transportValue: Insert | Insert[] - ) => ReturnType - return upsert(encodeTransportValue(value)) - } - - /** - * Extract the first item from a query response - * (PostgREST helper method) - * - * @param res - Response from a PostgREST query - * @param schema - Optional schema for parsing the item - * @returns Parsed first item - */ - public first(res: { data: unknown[] | null }, schema?: { parse(input: unknown): T }): T { - const item = res.data?.[0] - if (item === undefined) { - throw new APIError(`Relation ${this.relation} returned no rows.`, 404) - } - if (schema) return schema.parse(item) - if (this.defSchema) return this.defSchema.parse(item) as unknown as T - return item as T - } } diff --git a/packages/core/src/info-base/storages/blob.ts b/packages/core/src/info-base/storages/blob.ts new file mode 100644 index 00000000..ace08b54 --- /dev/null +++ b/packages/core/src/info-base/storages/blob.ts @@ -0,0 +1,87 @@ +import { z } from 'zod' + +import { + APIError, + DatabaseClient, + rawPostgrestFetch, + type DatabaseConnection, +} from '../../base/client' + +const PostgreSQLBlobPointerSchema = z.object({ + blob_id: z.uuid(), +}) + +export type PostgreSQLBlobPointer = z.infer + +function parsePointer(blockContent: string): PostgreSQLBlobPointer { + return PostgreSQLBlobPointerSchema.parse(JSON.parse(blockContent)) +} + +function serializePointer(pointer: PostgreSQLBlobPointer): string { + return JSON.stringify(PostgreSQLBlobPointerSchema.parse(pointer)) +} + +function postgresBytea(content: Uint8Array): string { + let hexadecimal = '\\x' + for (const byte of content) hexadecimal += byte.toString(16).padStart(2, '0') + return hexadecimal +} + +/** PostgreSQL/PostgREST peer-local byte storage with a stable UUID pointer. */ +export class PostgreSQLBlobStorage { + constructor( + private readonly request: (path: string, options: RequestInit) => Promise, + private readonly blobs: DatabaseClient<'storage_blobs'> + ) {} + + static forConnection(connection: DatabaseConnection): PostgreSQLBlobStorage { + const bound = { ...connection } + return new PostgreSQLBlobStorage( + (path, options) => rawPostgrestFetch(path, { ...options, ...bound }), + new DatabaseClient('storage_blobs', undefined, bound) + ) + } + + async getRawContent(blockContent: string): Promise { + const pointer = parsePointer(blockContent) + const response = await this.request('/rpc/read_storage_blob', { + method: 'POST', + headers: { + Accept: 'application/octet-stream', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(pointer), + }) + return new Uint8Array(await response.arrayBuffer()) + } + + async createRawContent(content: Uint8Array): Promise { + const response = await this.request('/rpc/create_storage_blob', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/octet-stream', + }, + body: Uint8Array.from(content).buffer as ArrayBuffer, + }) + const blobId = z.uuid().parse(await response.json()) + return serializePointer({ blob_id: blobId }) + } + + async updateRawContent(blockContent: string, content: Uint8Array): Promise { + const pointer = parsePointer(blockContent) + const { data, error } = await this.blobs + .update({ data: postgresBytea(content) }) + .eq('id', pointer.blob_id) + .select('id') + if (error) throw new APIError(error.message, 400, error) + return data?.length === 1 + } + + async deleteRawContent(blockContent: string): Promise { + const pointer = parsePointer(blockContent) + const { data, error } = await this.blobs.from().delete().eq('id', pointer.blob_id).select('id') + if (error) throw new APIError(error.message, 400, error) + return data?.length === 1 + } +} diff --git a/packages/core/src/info-base/storages/postgresql.ts b/packages/core/src/info-base/storages/postgresql.ts index 995e636f..482f7cde 100644 --- a/packages/core/src/info-base/storages/postgresql.ts +++ b/packages/core/src/info-base/storages/postgresql.ts @@ -1,81 +1,36 @@ -import { z } from 'zod' - -import { APIError, DBAPIClient, rawPostgrestFetch } from '../../base/db-api' +import { DBAPIClient, rawPostgrestFetch } from '../../base/db-api' import { Storage, WritableStorage, type IStorageBlock } from './base' +import { PostgreSQLBlobStorage } from './blob' +export type { PostgreSQLBlobPointer } from './blob' -const PostgreSQLBlobPointerSchema = z.object({ - blob_id: z.uuid(), -}) - -export type PostgreSQLBlobPointer = z.infer - -function parsePointer(blockContent: string): PostgreSQLBlobPointer { - return PostgreSQLBlobPointerSchema.parse(JSON.parse(blockContent)) -} - -function serializePointer(pointer: PostgreSQLBlobPointer): string { - return JSON.stringify(PostgreSQLBlobPointerSchema.parse(pointer)) -} - -function postgresBytea(content: Uint8Array): string { - let hexadecimal = '\\x' - for (const byte of content) hexadecimal += byte.toString(16).padStart(2, '0') - return hexadecimal -} - -/** PostgreSQL/PostgREST peer-local byte storage with a stable UUID pointer. */ +/** Vue model compatibility; byte operations are implemented by the standalone SDK. */ export class PostgreSQLBinaryStorage extends WritableStorage { private static blobApi = new DBAPIClient<'storage_blobs'>('storage_blobs') + private readonly content: PostgreSQLBlobStorage constructor( data: ConstructorParameters[0], - private readonly request: typeof rawPostgrestFetch = rawPostgrestFetch, - private readonly blobs = PostgreSQLBinaryStorage.blobApi + request: typeof rawPostgrestFetch = rawPostgrestFetch, + blobs = PostgreSQLBinaryStorage.blobApi ) { super(data) + this.content = new PostgreSQLBlobStorage(request, blobs) } - protected async _getRawContent(block: IStorageBlock): Promise { - const pointer = parsePointer(block.content) - const response = await this.request('/rpc/read_storage_blob', { - method: 'POST', - headers: { - Accept: 'application/octet-stream', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(pointer), - }) - return new Uint8Array(await response.arrayBuffer()) + protected _getRawContent(block: IStorageBlock): Promise { + return this.content.getRawContent(block.content) } - async createRawContent(content: Uint8Array): Promise { - const response = await this.request('/rpc/create_storage_blob', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/octet-stream', - }, - body: Uint8Array.from(content).buffer as ArrayBuffer, - }) - const blobId = z.uuid().parse(await response.json()) - return serializePointer({ blob_id: blobId }) + createRawContent(content: Uint8Array): Promise { + return this.content.createRawContent(content) } - async updateRawContent(blockContent: string, content: Uint8Array): Promise { - const pointer = parsePointer(blockContent) - const { data, error } = await this.blobs - .update({ data: postgresBytea(content) }) - .eq('id', pointer.blob_id) - .select('id') - if (error) throw new APIError(error.message, 400, error) - return data?.length === 1 + updateRawContent(pointer: string, content: Uint8Array): Promise { + return this.content.updateRawContent(pointer, content) } - async deleteRawContent(blockContent: string): Promise { - const pointer = parsePointer(blockContent) - const { data, error } = await this.blobs.from().delete().eq('id', pointer.blob_id).select('id') - if (error) throw new APIError(error.message, 400, error) - return data?.length === 1 + deleteRawContent(pointer: string): Promise { + return this.content.deleteRawContent(pointer) } } diff --git a/packages/core/src/peer/contracts.ts b/packages/core/src/peer/contracts.ts index 9bd79893..6f197058 100644 --- a/packages/core/src/peer/contracts.ts +++ b/packages/core/src/peer/contracts.ts @@ -1,4 +1,4 @@ -import type { Context } from '../obsrv/telemetry' +import type { Context } from '@opentelemetry/api' import { z } from 'zod' export type JsonValue = diff --git a/packages/core/src/peer/runtime.ts b/packages/core/src/peer/runtime.ts index d74c6da4..f17b468b 100644 --- a/packages/core/src/peer/runtime.ts +++ b/packages/core/src/peer/runtime.ts @@ -1,13 +1,13 @@ import { z } from 'zod' -import { signDatabaseToken } from '../auth' -import { APIError, DBAPIClient } from '../base' +import { signDatabaseToken } from '../auth/sign' +import { APIError, DatabaseClient } from '../base/client' import { MetaConfigSchema, PeerConfigSchema, type MetaConfig } from '../config/schema' import { Peer } from './peer' export const WEB_PEER_LEASE_TTL_SECONDS = 90 export const WEB_PEER_LEASE_RENEW_INTERVAL_MS = 30_000 -type PeerDatabase = DBAPIClient<'peers', Peer> +type PeerDatabase = DatabaseClient<'peers', Peer> export interface WebPeerIdentity { applicationVersion: string @@ -117,13 +117,10 @@ export class WebPeerRuntime { static forConnection(meta: MetaConfig, identity: WebPeerIdentity): WebPeerRuntime { const exactMeta = MetaConfigSchema.parse(meta) - const database = new DBAPIClient<'peers', Peer>( - 'peers', - Peer, - 'inkcre', - exactMeta.INKCRE_PGREST_URL, - () => signDatabaseToken(exactMeta.INKCRE_JWT_SECRET) - ) + const database = new DatabaseClient<'peers', Peer>('peers', Peer, { + baseUrl: exactMeta.INKCRE_PGREST_URL, + tokenProvider: () => signDatabaseToken(exactMeta.INKCRE_JWT_SECRET), + }) return new WebPeerRuntime( exactMeta.INKCRE_PEER_ID, identity, diff --git a/packages/core/src/sdk.ts b/packages/core/src/sdk.ts new file mode 100644 index 00000000..bc2c529d --- /dev/null +++ b/packages/core/src/sdk.ts @@ -0,0 +1,12 @@ +/** Independent SDK entry. Importing it does not select a connection or start a Peer. */ +export { DatabaseClient, APIError, rawPostgrestFetch } from './base/client' +export type { + DatabaseConnection, + DatabaseTokenProvider, + RawPostgrestRequestOptions, +} from './base/client' +export { signDatabaseToken } from './auth/sign' +export * from './database' +export * from './peer/contracts' +export * from './info-base/resolvers/contracts' +export { PostgreSQLBlobStorage, type PostgreSQLBlobPointer } from './info-base/storages/blob' diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 426b7897..1ca641c0 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,19 +1,17 @@ import { defineConfig } from 'tsdown' -export default defineConfig({ - entry: ['src/index.ts'], - format: 'esm', - platform: 'neutral', +const common = { + format: 'esm' as const, + platform: 'neutral' as const, target: 'esnext', - clean: true, sourcemap: true, treeshake: true, - deps: { - neverBundle: true, - }, - dts: { - generator: 'tsc', - resolver: 'tsc', - sourcemap: true, - }, -}) + deps: { neverBundle: true }, + dts: { generator: 'tsc' as const, resolver: 'tsc' as const, sourcemap: true }, +} + +// Separate graphs keep the SDK's declarations independent of Vue peer dependencies. +export default defineConfig([ + { ...common, entry: ['src/index.ts'], outDir: 'dist', clean: false }, + { ...common, entry: ['src/sdk.ts'], outDir: 'dist/sdk', clean: false }, +]) diff --git a/scripts/tests/core-independent.test.mjs b/scripts/tests/core-independent.test.mjs new file mode 100644 index 00000000..14525ac4 --- /dev/null +++ b/scripts/tests/core-independent.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +const root = fileURLToPath(new URL('../..', import.meta.url)) + +void test('packed SDK works without UI, source aliases, startup or selected App connection', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'inkcre-sdk-')) + try { + const packageRoot = path.join(root, 'packages/core') + const metadata = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')) + const packed = JSON.parse( + execFileSync('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', directory], { + cwd: packageRoot, + encoding: 'utf8', + }) + ) + const destination = path.join(directory, 'node_modules/@inkcre/core') + await mkdir(destination, { recursive: true }) + execFileSync('tar', [ + '-xzf', + path.join(directory, packed[0].filename), + '--strip-components=1', + '-C', + destination, + ]) + for (const dependency of Object.keys(metadata.dependencies)) { + const link = path.join(directory, 'node_modules', dependency) + await mkdir(path.dirname(link), { recursive: true }) + await symlink(path.join(packageRoot, 'node_modules', dependency), link) + } + for (const dependency of ['vue', 'pinia', '@vueuse/core']) { + assert.equal(metadata.peerDependenciesMeta[dependency].optional, true) + } + await writeFile( + path.join(directory, 'consumer.mjs'), + ` + import assert from 'node:assert/strict' + globalThis.fetch = () => { throw new Error('Unexpected network/startup') } + const { DatabaseClient, PostgreSQLBlobStorage, rawPostgrestFetch, PeerOutcomeUnknown } = await import('@inkcre/core/sdk') + assert.equal(typeof PeerOutcomeUnknown, 'function') + const calls = [] + let finishA + const transport = async (input, init) => { + calls.push({ url: String(input), token: new Headers(init.headers).get('Authorization'), body: init.body }) + if (String(input).includes('read_storage_blob')) return new Response(new Uint8Array([1, 2, 3])) + if (String(input).startsWith('https://a.example') && !finishA) await new Promise(resolve => { finishA = resolve }) + return Response.json([{ id: 1, content: 'retained', resolver: 'core.text.v1', storage: null }]) + } + const selected = { baseUrl: 'https://a.example', tokenProvider: async () => 'token-a', fetch: transport } + const a = new DatabaseClient('blocks', undefined, selected) + const pendingA = a.from().select().then(value => value) + while (!finishA) await new Promise(resolve => setTimeout(resolve, 0)) + selected.baseUrl = 'https://b.example' + selected.tokenProvider = async () => 'token-b' + const b = new DatabaseClient('blocks', undefined, selected) + const resultB = await b.insert({ content: 'B', resolver: 'core.text.v1' }).select() + finishA() + const resultA = await pendingA + assert.equal(resultA.data[0].id, 1) + assert.equal(resultB.data[0].id, 1) + assert.equal(calls[0].url.startsWith('https://a.example/'), true) + assert.equal(calls[0].token, 'Bearer token-a') + assert.equal(calls[1].url.startsWith('https://b.example/'), true) + assert.equal(calls[1].token, 'Bearer token-b') + assert.equal(JSON.parse(calls[1].body).content, 'B') + const storage = PostgreSQLBlobStorage.forConnection(selected) + selected.baseUrl = 'https://c.example' + const bytes = await storage.getRawContent(JSON.stringify({ blob_id: '00000000-0000-4000-8000-000000000001' })) + assert.deepEqual([...bytes], [1, 2, 3]) + assert.equal(calls[2].url.startsWith('https://b.example/'), true) + console.log('Packed independent SDK: import, isolated targets, typed writes and binary storage passed') + ` + ) + execFileSync(process.execPath, ['consumer.mjs'], { cwd: directory, stdio: 'inherit' }) + await writeFile( + path.join(directory, 'consumer.mts'), + ` + import { DatabaseClient, type DatabaseConnection, type RelationRow } from '@inkcre/core/sdk' + const connection: DatabaseConnection = { baseUrl: 'https://example.test', tokenProvider: async () => 'unused' } + const client = new DatabaseClient('blocks', undefined, connection) + const response = await client.from().select() + const blocks: RelationRow<'blocks'>[] | null = response.data + void blocks + ` + ) + execFileSync( + process.execPath, + [ + path.join(root, 'node_modules/typescript/bin/tsc'), + '--noEmit', + '--strict', + '--target', + 'esnext', + '--module', + 'nodenext', + 'consumer.mts', + ], + { + cwd: directory, + stdio: 'inherit', + } + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) From 53e6fd7089497697ac953d31ac3a690d934aa5c9 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 4 Nov 2026 20:54:25 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(core):=20=E5=BC=95=E7=94=A8=20Core=20?= =?UTF-8?q?=E4=B8=8E=20App=20=E8=BE=B9=E7=95=8C=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index 4e323b49..b3334622 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 4e323b49ffcd00011f7e008fbb53866c6bec5a27 +Subproject commit b33346225b669a6ac93fcac12a135178a46533c8 From 3dc60a517fbb943a83858f686297b68a3cad4e9a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 5 Nov 2026 17:47:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(core):=20=E5=BC=95=E7=94=A8=E5=B7=B2?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E7=9A=84=E5=85=B1=E4=BA=AB=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新到 Hub main 17fc19e - 使用与正式 dogfood 相同的合同树交付 Core 与 App 边界 --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index b3334622..17fc19e7 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit b33346225b669a6ac93fcac12a135178a46533c8 +Subproject commit 17fc19e7230187a7a989815e7b8303bbb380f478