Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/core-app-separation.md
Original file line number Diff line number Diff line change
@@ -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。
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的默认连接。
3 changes: 2 additions & 1 deletion FILESYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/_shared
Submodule _shared updated 57 files
+6 −6 .github/workflows/pages-deploy.yml
+2 −2 .github/workflows/pages-preview-cleanup.yml
+3 −3 .github/workflows/pages-preview.yml
+2 −2 .github/workflows/website-check.yml
+6 −0 10-prd/behavior/capabilities.md
+27 −0 10-prd/behavior/observability.md
+4 −0 10-prd/behavior/rules-and-invariants.md
+4 −0 10-prd/behavior/workflows.md
+3 −3 10-prd/domain-structure/cross-domain-interactions.md
+1 −1 10-prd/domain-structure/derived-boundaries.md
+9 −4 10-prd/glossary.md
+1 −0 10-prd/index.md
+145 −0 20-product-tdd/core-app-boundary.md
+4 −0 20-product-tdd/cross-unit-contracts.md
+3 −3 20-product-tdd/extension-documentation-hosting.md
+2 −2 20-product-tdd/extension-release-and-installation.md
+4 −4 20-product-tdd/feature-retrieval-and-media-interpretation.md
+1 −1 20-product-tdd/graph-navigation-retrieval.md
+2 −2 20-product-tdd/knowledge-capability-contract.md
+58 −8 20-product-tdd/observability-contract.md
+1 −1 20-product-tdd/peer-database-runtime-contract.md
+3 −3 20-product-tdd/system-state-and-authority.md
+8 −2 20-product-tdd/unit-topology.md
+1 −0 docs/index.md
+5 −5 website/README.md
+3 −3 website/content/en/developer/architecture.md
+1 −1 website/content/en/developer/contributing.md
+2 −2 website/content/en/developer/index.md
+2 −2 website/content/en/getting-started.md
+1 −1 website/content/en/guide/collect.md
+2 −2 website/content/en/guide/connect.md
+1 −1 website/content/en/guide/daily-use.md
+1 −1 website/content/en/guide/first-source.md
+1 −1 website/content/en/guide/schedules.md
+2 −2 website/content/en/guide/search.md
+1 −1 website/content/en/guide/sources/mail.md
+1 −1 website/content/en/guide/sources/memos.md
+1 −1 website/content/en/guide/sources/rss.md
+4 −4 website/content/en/guide/sources/twitter.md
+1 −1 website/content/en/guide/troubleshooting.md
+1 −1 website/content/en/index.md
+8 −8 website/content/en/self-hosted/custom.md
+1 −1 website/content/en/self-hosted/index.md
+1 −0 website/content/public/_redirects
+ − website/content/public/images/web/collection-job.png
+ − website/content/public/images/web/create-source-type.png
+ − website/content/public/images/web/info-base-graph.png
+ − website/content/public/images/web/lexical-search.png
+ − website/content/public/images/web/mail-content.png
+ − website/content/public/images/web/memos-setup.png
+ − website/content/public/images/web/peers-overview.png
+ − website/content/public/images/web/settings-overview.png
+ − website/content/public/images/web/source-schedule.png
+ − website/content/public/images/web/sources-overview.png
+ − website/content/public/images/web/twitter-setup.png
+2 −2 website/package.json
+350 −404 website/pnpm-lock.yaml
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
@@ -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 声明编译、并发目标隔离、序列化和二进制请求。测试使用受控传输,不连接真实数据库;构建与本地检查不能替代跨语言端到端验收。
17 changes: 16 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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
}
}
}
20 changes: 20 additions & 0 deletions packages/core/src/auth/sign.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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)
}
21 changes: 2 additions & 19 deletions packages/core/src/auth/store.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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.
Expand Down
175 changes: 175 additions & 0 deletions packages/core/src/base/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { PostgrestClient } from '@supabase/postgrest-js'
import type { Database, InkcreSchema, RelationName, RelationRow } from '../database'

type RelationInsert<Relation extends RelationName> = InkcreSchema['Tables'][Relation]['Insert']
type RelationUpdate<Relation extends RelationName> = InkcreSchema['Tables'][Relation]['Update']
export type DatabaseTokenProvider = () => Promise<string>

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>(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<Response> {
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<Relation>,
> extends PostgrestClient<Database, {}, 'inkcre'> {
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<Relation>
const query = super.from(this.relation)
const insert = query.insert.bind(query) as unknown as (
transportValue: Insert | Insert[]
) => ReturnType<typeof query.insert>
return insert(encodeTransportValue<Insert | Insert[]>(value))
}

public update(value: unknown) {
type Update = RelationUpdate<Relation>
const query = super.from(this.relation)
const update = query.update.bind(query) as unknown as (
transportValue: Update
) => ReturnType<typeof query.update>
return update(encodeTransportValue<Update>(value))
}

public upsert(value: unknown) {
type Insert = RelationInsert<Relation>
const query = super.from(this.relation)
const upsert = query.upsert.bind(query) as unknown as (
transportValue: Insert | Insert[]
) => ReturnType<typeof query.upsert>
return upsert(encodeTransportValue<Insert | Insert[]>(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<T = DT>(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
}
}
Loading
Loading