From 1a3003e7bb5ad7f76669f26d82e2592b14e77d52 Mon Sep 17 00:00:00 2001 From: guozhihao-224 Date: Sat, 29 Aug 2026 16:51:20 +0800 Subject: [PATCH 1/2] Add an experimental sqlite-fts persistence skeleton for language-switch pits. Port Analyzer v1 and a node:sqlite runtime that can remember and FTS-search self-created databases, while refusing unstamped Python-like files and without claiming C3 or shared-database writes. --- CHANGELOG.md | 5 + packages/builtin/README.md | 24 +- packages/builtin/package.json | 2 +- packages/builtin/src/index.ts | 19 + .../builtin/src/persistence/artifact-store.ts | 222 ++++++++++ .../builtin/src/persistence/memory-store.ts | 281 ++++++++++++ .../builtin/src/persistence/schema-gate.ts | 158 +++++++ .../builtin/src/persistence/source-store.ts | 103 +++++ .../builtin/src/persistence/sqlite-session.ts | 133 ++++++ packages/builtin/src/runtime.ts | 69 +++ packages/builtin/tests/artifact-store.test.ts | 61 +++ packages/builtin/tests/memory-store.test.ts | 79 ++++ packages/builtin/tests/schema-gate.test.ts | 76 ++++ packages/builtin/tests/source-store.test.ts | 46 ++ packages/builtin/tests/sqlite-session.test.ts | 99 +++++ packages/core/src/canonical/analyzer.ts | 419 ++++++++++++++++++ packages/core/src/index.ts | 6 + packages/core/tests/analyzer-oracle.test.ts | 164 +++++++ packages/core/tests/analyzer.test.ts | 64 +++ 19 files changed, 2027 insertions(+), 3 deletions(-) create mode 100644 packages/builtin/src/persistence/artifact-store.ts create mode 100644 packages/builtin/src/persistence/memory-store.ts create mode 100644 packages/builtin/src/persistence/schema-gate.ts create mode 100644 packages/builtin/src/persistence/source-store.ts create mode 100644 packages/builtin/src/persistence/sqlite-session.ts create mode 100644 packages/builtin/src/runtime.ts create mode 100644 packages/builtin/tests/artifact-store.test.ts create mode 100644 packages/builtin/tests/memory-store.test.ts create mode 100644 packages/builtin/tests/schema-gate.test.ts create mode 100644 packages/builtin/tests/source-store.test.ts create mode 100644 packages/builtin/tests/sqlite-session.test.ts create mode 100644 packages/core/src/canonical/analyzer.ts create mode 100644 packages/core/tests/analyzer-oracle.test.ts create mode 100644 packages/core/tests/analyzer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4be361b..92a8c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ are published. ### Added +- Experimental `@powercontext/builtin` Node `node:sqlite` persistence skeleton: + Analyzer v1-projected FTS, guarded schema stamp, Source/Artifact persistence, + CAS, and minimal Memory remember/list/get/search. It is not C3 and does not + write the shared Python database; `DatabaseSync` event-loop blocking remains + an explicit documented pit. - `@powercontext/core` deterministic primitives for the `sqlite-fts` profile: RFC 8785 JCS, recursive NFC, domain-separated SHA-256, UTF-8 byte budgets, Source/Artifact refs, Trigger contracts, and fake-store diff --git a/packages/builtin/README.md b/packages/builtin/README.md index fc52554..e76eee6 100644 --- a/packages/builtin/README.md +++ b/packages/builtin/README.md @@ -1,4 +1,24 @@ # @powercontext/builtin -Official Runtime, persistence and inference adapters. Node-only. -Persistence writes wait for ADR 0001 and ADR 0002. +Experimental Node-only persistence skeleton for testing the TypeScript +language-switch pits. It uses Node `node:sqlite` `DatabaseSync`, an explicit +experimental database stamp, and SQLite FTS5 with the `powercontext.analyzer.v1` +lexical algorithm from `@powercontext/core`. + +This is not the product Runtime, not C3, and not a replacement for the shared +Python database. `SHARED_DATABASE_WRITES_ALLOWED` remains `false`; unstamped or +foreign databases refuse writes without running DDL. Vector and hybrid search +are intentionally unavailable. + +`DatabaseSync` is synchronous and can block the Node event loop during SQLite +work. The skeleton keeps that limitation visible rather than hiding it behind +an asynchronous-looking API. + +## Pits found + +- `DatabaseSync` blocks the event loop during synchronous SQLite work. +- Exclusive-writer detection is process-local; two OS processes can still open + the same database because this skeleton does not add a cross-process lock. +- Analyzer parity is covered by an optional pinned-Python oracle. Run + `POWERCONTEXT_SKELETON_ORACLE=1 POWERCONTEXT_PYTHON_ROOT= pnpm test packages/core/tests/analyzer-oracle.test.ts` + with the checkout at commit `733e4bf6b378785e76274ff07632029c699ecb09`. diff --git a/packages/builtin/package.json b/packages/builtin/package.json index 341f8eb..cf31958 100644 --- a/packages/builtin/package.json +++ b/packages/builtin/package.json @@ -1,7 +1,7 @@ { "name": "@powercontext/builtin", "version": "0.0.0", - "description": "Official Node Runtime, persistence and inference adapters.", + "description": "Experimental Node persistence skeleton and adapters.", "license": "Apache-2.0", "type": "module", "sideEffects": false, diff --git a/packages/builtin/src/index.ts b/packages/builtin/src/index.ts index 0214dd3..8f7629e 100644 --- a/packages/builtin/src/index.ts +++ b/packages/builtin/src/index.ts @@ -19,3 +19,22 @@ export const PACKAGE_VERSION = '0.0.0' as const export const PACKAGE_ROLE = 'builtin' as const export const PACKAGE_PROFILE = 'sqlite-fts' as const export const SHARED_DATABASE_WRITES_ALLOWED = false + +export { ExperimentalRuntime, openExperimentalRuntime } from './runtime.js' +export { SQLiteArtifactStore } from './persistence/artifact-store.js' +export { + SQLiteMemoryStore, + type MemoryEntry, + type MemorySearchInput, + type RememberInput, +} from './persistence/memory-store.js' +export { + EXPERIMENTAL_DATABASE_STAMP, + EXPERIMENTAL_SCHEMA_KIND, + SchemaGateError, + experimentalSchemaDdl, + inspectSchemaGate, + type SchemaGateKind, +} from './persistence/schema-gate.js' +export { SQLiteSession, openSQLiteSession } from './persistence/sqlite-session.js' +export { SQLiteSourceStore } from './persistence/source-store.js' diff --git a/packages/builtin/src/persistence/artifact-store.ts b/packages/builtin/src/persistence/artifact-store.ts new file mode 100644 index 0000000..9b05ece --- /dev/null +++ b/packages/builtin/src/persistence/artifact-store.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ArtifactFamilyMismatchError, + ArtifactNotFoundError, + RevisionConflictError, + canonicalizeJson, + bigintToSafeInteger, + createArtifact, + createArtifactRef, + createSourceRef, +} from '@powercontext/core' +import type { + Artifact, + ArtifactCatalog, + ArtifactDraft, + ArtifactRef, + ArtifactStore, +} from '@powercontext/core' +import { normalizeRefs } from '@powercontext/core' +import type { SQLiteSession } from './sqlite-session.js' + +type ArtifactRow = { + readonly family: string + readonly artifact_id: string + readonly revision: bigint + readonly content_json: string + readonly source_refs_json: string + readonly artifact_refs_json: string +} + +function refOf(value: Artifact | ArtifactRef): ArtifactRef { + return typeof (value as Artifact).asRef === 'function' + ? (value as Artifact).asRef() + : (value as ArtifactRef) +} + +function rowToArtifact(row: ArtifactRow): Artifact { + const revision = bigintToSafeInteger(row.revision, 'revision') + const sourceRefs = ( + JSON.parse(row.source_refs_json) as Array<{ + source_type: string + source_id: string + }> + ).map((ref) => createSourceRef(ref.source_type, ref.source_id)) + const artifactRefs = ( + JSON.parse(row.artifact_refs_json) as Array<{ + family: string + artifact_id: string + revision: number + }> + ).map((ref) => createArtifactRef(ref.family, ref.artifact_id, ref.revision)) + return createArtifact({ + family: row.family, + artifactId: row.artifact_id, + revision, + content: JSON.parse(row.content_json) as unknown, + lineage: { + sources: sourceRefs, + artifacts: artifactRefs, + }, + }) +} + +export class SQLiteArtifactStore + implements ArtifactStore, ArtifactCatalog +{ + constructor(private readonly session: SQLiteSession) {} + + async create(artifactId: string, draft: ArtifactDraft): Promise { + return this.session.transaction(() => { + const current = this.headRow(draft.family, artifactId) + if (current !== undefined) { + throw new RevisionConflictError( + createArtifactRef(draft.family, artifactId, 1), + rowToArtifact(current), + ) + } + return this.insertRevision(artifactId, draft, 1) + }) + } + + async revise(artifact: Artifact, draft: ArtifactDraft): Promise { + if (artifact.family !== draft.family) { + throw new ArtifactFamilyMismatchError(artifact, draft) + } + return this.session.transaction(() => { + const current = this.headRow(artifact.family, artifact.artifactId) + const actualRevision = + current === undefined ? null : bigintToSafeInteger(current.revision, 'revision') + if (current === undefined || actualRevision !== artifact.revision) { + throw new RevisionConflictError( + artifact, + current === undefined ? null : rowToArtifact(current), + ) + } + return this.insertRevision(artifact.artifactId, draft, artifact.revision + 1) + }) + } + + async get(target: ArtifactRef | Artifact): Promise { + const ref = refOf(target) + const row = this.session + .prepare( + 'SELECT family, artifact_id, revision, content_json, source_refs_json, artifact_refs_json FROM pc_artifact_version WHERE family = ? AND artifact_id = ? AND revision = ?', + ) + .get(ref.family, ref.artifactId, ref.revision) as ArtifactRow | undefined + if (row === undefined) { + throw new ArtifactNotFoundError(ref) + } + return rowToArtifact(row) + } + + async latest(artifact: Artifact): Promise + async latest(family: string, artifactId: string): Promise + async latest( + familyOrArtifact: string | Artifact, + artifactId?: string, + ): Promise { + const family = + typeof familyOrArtifact === 'string' ? familyOrArtifact : familyOrArtifact.family + const id = + typeof familyOrArtifact === 'string' + ? (artifactId ?? '') + : familyOrArtifact.artifactId + const row = this.headRow(family, id) + if (row === undefined) { + throw new ArtifactNotFoundError(createArtifactRef(family, id || 'missing', 1)) + } + return rowToArtifact(row) + } + + async revisions(artifact: Artifact): Promise + async revisions(family: string, artifactId: string): Promise + async revisions( + familyOrArtifact: string | Artifact, + artifactId?: string, + ): Promise { + const family = + typeof familyOrArtifact === 'string' ? familyOrArtifact : familyOrArtifact.family + const id = + typeof familyOrArtifact === 'string' + ? (artifactId ?? '') + : familyOrArtifact.artifactId + const rows = this.session + .prepare( + 'SELECT family, artifact_id, revision, content_json, source_refs_json, artifact_refs_json FROM pc_artifact_version WHERE family = ? AND artifact_id = ? ORDER BY revision', + ) + .all(family, id) as ArtifactRow[] + return rows.map(rowToArtifact) + } + + insertRevision(artifactId: string, draft: ArtifactDraft, revision: number): Artifact { + const sources = normalizeRefs(draft.sources) + const artifacts = normalizeRefs(draft.artifacts) + const contentJson = canonicalizeJson(draft.content) + const sourceRefsJson = canonicalizeJson(sources) + const artifactRefsJson = canonicalizeJson(artifacts) + const typedSources = ( + sources as Array<{ + source_type: string + source_id: string + }> + ).map((ref) => createSourceRef(ref.source_type, ref.source_id)) + const typedArtifacts = ( + artifacts as Array<{ + family: string + artifact_id: string + revision: number + }> + ).map((ref) => createArtifactRef(ref.family, ref.artifact_id, ref.revision)) + this.session + .prepare( + 'INSERT INTO pc_artifact_version(family, artifact_id, revision, content_json, source_refs_json, artifact_refs_json) VALUES (?, ?, ?, ?, ?, ?)', + ) + .run( + draft.family, + artifactId, + revision, + contentJson, + sourceRefsJson, + artifactRefsJson, + ) + this.session + .prepare( + 'INSERT INTO pc_artifact_head(family, artifact_id, revision) VALUES (?, ?, ?) ON CONFLICT(family, artifact_id) DO UPDATE SET revision = excluded.revision', + ) + .run(draft.family, artifactId, revision) + return createArtifact({ + family: draft.family, + artifactId, + revision, + content: JSON.parse(contentJson) as unknown, + lineage: { + sources: typedSources, + artifacts: typedArtifacts, + }, + }) + } + + private headRow(family: string, artifactId: string): ArtifactRow | undefined { + return this.session + .prepare( + 'SELECT v.family, v.artifact_id, v.revision, v.content_json, v.source_refs_json, v.artifact_refs_json FROM pc_artifact_head h JOIN pc_artifact_version v ON v.family = h.family AND v.artifact_id = h.artifact_id AND v.revision = h.revision WHERE h.family = ? AND h.artifact_id = ?', + ) + .get(family, artifactId) as ArtifactRow | undefined + } +} diff --git a/packages/builtin/src/persistence/memory-store.ts b/packages/builtin/src/persistence/memory-store.ts new file mode 100644 index 0000000..f60cea2 --- /dev/null +++ b/packages/builtin/src/persistence/memory-store.ts @@ -0,0 +1,281 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ENTRY_CONTENT_HASH_DOMAIN, + MAX_SCOPE_ID_LENGTH, + MEMORY_ENTRY_TEXT_MAX_BYTES, + UnavailableError, + ValidationError, + admitsFtsText, + analyzeText, + assertUtf8Budget, + canonicalizeJson, + codePointLength, + createArtifactDraft, + ftsMatchQuery, + hashDomain, + normalizeRefs, + normalizeUnicode, +} from '@powercontext/core' +import type { ArtifactRef, SourceRef } from '@powercontext/core' +import { SQLiteArtifactStore } from './artifact-store.js' +import type { SQLiteSession } from './sqlite-session.js' + +export interface RememberInput { + readonly scope_id: string + readonly kind: string + readonly text: string + readonly source_refs?: readonly SourceRef[] + readonly artifact_refs?: readonly ArtifactRef[] +} + +export interface MemoryEntry { + readonly scope_id: string + readonly entry_id: string + readonly kind: string + readonly text: string + readonly content_hash: string + readonly source_refs: readonly { + readonly source_type: string + readonly source_id: string + }[] + readonly artifact_refs: readonly { + readonly family: string + readonly artifact_id: string + readonly revision: number + }[] + readonly artifact: ArtifactRef + readonly created_at: string +} + +export interface MemorySearchInput { + readonly scope_id: string + readonly query: string + readonly mode?: 'fts' | 'vector' | 'hybrid' + readonly limit?: number +} + +type EntryRow = { + readonly scope_id: string + readonly entry_id: string + readonly kind: string + readonly text: string + readonly content_hash: string + readonly source_refs_json: string + readonly artifact_refs_json: string + readonly artifact_id: string + readonly artifact_revision: bigint + readonly created_at: string +} + +function normalizeIdentity(value: string, field: string, maximum: number): string { + const normalized = value.normalize('NFC') + if (normalized.length === 0 || normalized !== normalized.trim()) { + throw new ValidationError(`${field} must be a trimmed non-empty string`) + } + if (codePointLength(normalized) > maximum) { + throw new ValidationError(`${field} must not exceed ${String(maximum)} characters`) + } + return normalized +} + +function entryFromRow(session: SQLiteSession, row: EntryRow): MemoryEntry { + return Object.freeze({ + scope_id: row.scope_id, + entry_id: row.entry_id, + kind: row.kind, + text: row.text, + content_hash: row.content_hash, + source_refs: Object.freeze( + JSON.parse(row.source_refs_json) as MemoryEntry['source_refs'], + ), + artifact_refs: Object.freeze( + JSON.parse(row.artifact_refs_json) as MemoryEntry['artifact_refs'], + ), + artifact: Object.freeze({ + family: 'memory', + artifactId: row.artifact_id, + revision: session.safeInteger(row.artifact_revision, 'artifact_revision'), + }), + created_at: row.created_at, + }) +} + +const ENTRY_COLUMNS = + 'scope_id, entry_id, kind, text, content_hash, source_refs_json, artifact_refs_json, artifact_id, artifact_revision, created_at' + +function qualifiedColumns(alias: string): string { + return ENTRY_COLUMNS.split(', ') + .map((column) => `${alias}.${column}`) + .join(', ') +} + +export class SQLiteMemoryStore { + private readonly artifacts: SQLiteArtifactStore + + constructor(private readonly session: SQLiteSession) { + this.artifacts = new SQLiteArtifactStore(session) + } + + async remember(input: RememberInput): Promise { + const scopeId = normalizeIdentity(input.scope_id, 'scope_id', MAX_SCOPE_ID_LENGTH) + const kind = normalizeIdentity(input.kind, 'kind', 128) + const text = normalizeUnicode(input.text) as string + assertUtf8Budget(text, MEMORY_ENTRY_TEXT_MAX_BYTES, 'memory entry text') + const sourceRefs = normalizeRefs(input.source_refs ?? []) + const artifactRefs = normalizeRefs(input.artifact_refs ?? []) + const payload = Object.freeze({ + kind, + text, + source_refs: sourceRefs, + artifact_refs: artifactRefs, + }) + const contentHash = hashDomain(ENTRY_CONTENT_HASH_DOMAIN, payload) + const sourceRefsJson = canonicalizeJson(sourceRefs) + const artifactRefsJson = canonicalizeJson(artifactRefs) + const createdAt = new Date().toISOString() + + return this.session.transaction(() => { + const existing = this.find(scopeId, contentHash) + if (existing !== undefined) { + return entryFromRow(this.session, existing) + } + const artifactExists = this.session + .prepare( + 'SELECT 1 AS present FROM pc_artifact_head WHERE family = ? AND artifact_id = ?', + ) + .get('memory', contentHash) + if (artifactExists === undefined) { + this.artifacts.insertRevision( + contentHash, + createArtifactDraft({ + family: 'memory', + content: payload, + sources: input.source_refs ?? [], + artifacts: input.artifact_refs ?? [], + }), + 1, + ) + } + this.session + .prepare( + 'INSERT INTO pc_memory_entry_version(scope_id, entry_id, revision, kind, text, content_hash, source_refs_json, artifact_refs_json, artifact_family, artifact_id, artifact_revision, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ) + .run( + scopeId, + contentHash, + 1, + kind, + text, + contentHash, + sourceRefsJson, + artifactRefsJson, + 'memory', + contentHash, + 1, + createdAt, + ) + this.session + .prepare( + 'INSERT INTO pc_memory_entry_head(scope_id, entry_id, revision) VALUES (?, ?, ?)', + ) + .run(scopeId, contentHash, 1) + this.session + .prepare( + 'INSERT INTO pc_memory_entry_fts(scope_id, entry_id, searchable_text) VALUES (?, ?, ?)', + ) + .run(scopeId, contentHash, analyzeText(text)) + const row = this.find(scopeId, contentHash) + if (row === undefined) { + throw new ValidationError('remembered entry could not be read back') + } + return entryFromRow(this.session, row) + }) + } + + async listEntries(scopeId?: string): Promise { + const rows = + scopeId === undefined + ? (this.session + .prepare( + `SELECT ${qualifiedColumns('v')} FROM pc_memory_entry_head h JOIN pc_memory_entry_version v ON v.scope_id = h.scope_id AND v.entry_id = h.entry_id AND v.revision = h.revision ORDER BY v.created_at, v.entry_id`, + ) + .all() as EntryRow[]) + : (this.session + .prepare( + `SELECT ${qualifiedColumns('v')} FROM pc_memory_entry_head h JOIN pc_memory_entry_version v ON v.scope_id = h.scope_id AND v.entry_id = h.entry_id AND v.revision = h.revision WHERE v.scope_id = ? ORDER BY v.created_at, v.entry_id`, + ) + .all( + normalizeIdentity(scopeId, 'scope_id', MAX_SCOPE_ID_LENGTH), + ) as EntryRow[]) + return rows.map((row) => entryFromRow(this.session, row)) + } + + async getEntry(scopeId: string, entryId: string): Promise { + const row = this.find( + normalizeIdentity(scopeId, 'scope_id', MAX_SCOPE_ID_LENGTH), + entryId, + ) + if (row === undefined) { + throw new ValidationError('memory entry was not found') + } + return entryFromRow(this.session, row) + } + + async search(input: MemorySearchInput): Promise { + if ((input.mode ?? 'fts') !== 'fts') { + throw new UnavailableError(`${input.mode ?? 'fts'} memory search is unavailable`) + } + const match = ftsMatchQuery(input.query) + if (match === null) { + return [] + } + const limit = input.limit ?? 20 + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) { + throw new ValidationError('search limit must be an integer from 1 to 1000') + } + const scopeId = normalizeIdentity(input.scope_id, 'scope_id', MAX_SCOPE_ID_LENGTH) + const rows = this.session + .prepare( + `SELECT ${qualifiedColumns('v')} FROM pc_memory_entry_fts f JOIN pc_memory_entry_head h ON h.scope_id = f.scope_id AND h.entry_id = f.entry_id JOIN pc_memory_entry_version v ON v.scope_id = h.scope_id AND v.entry_id = h.entry_id AND v.revision = h.revision WHERE pc_memory_entry_fts MATCH ? AND f.scope_id = ? ORDER BY bm25(pc_memory_entry_fts), v.entry_id LIMIT ?`, + ) + .all(match, scopeId, limit) as EntryRow[] + return rows + .map((row) => entryFromRow(this.session, row)) + .filter((entry) => admitsFtsText(input.query, entry.text)) + } + + async revise(): Promise { + throw new UnavailableError('memory revise is unavailable in the skeleton') + } + + async retire(): Promise { + throw new UnavailableError('memory retire is unavailable in the skeleton') + } + + async listChanges(): Promise { + throw new UnavailableError('memory change listing is unavailable in the skeleton') + } + + private find(scopeId: string, entryId: string): EntryRow | undefined { + return this.session + .prepare( + `SELECT ${qualifiedColumns('v')} FROM pc_memory_entry_head h JOIN pc_memory_entry_version v ON v.scope_id = h.scope_id AND v.entry_id = h.entry_id AND v.revision = h.revision WHERE v.scope_id = ? AND v.entry_id = ?`, + ) + .get(scopeId, entryId) as EntryRow | undefined + } +} diff --git a/packages/builtin/src/persistence/schema-gate.ts b/packages/builtin/src/persistence/schema-gate.ts new file mode 100644 index 0000000..aee30f5 --- /dev/null +++ b/packages/builtin/src/persistence/schema-gate.ts @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UnavailableError } from '@powercontext/core' +import type { DatabaseSync } from 'node:sqlite' + +export const EXPERIMENTAL_DATABASE_STAMP = + 'powercontext.database.experimental-ts' as const +export const EXPERIMENTAL_SCHEMA_KIND = 'experimental-writable' as const + +export type SchemaGateKind = + 'experimental-writable' | 'foreign-write-refused' | 'unrecognized-refused' + +export class SchemaGateError extends UnavailableError { + readonly kind: Exclude + + constructor(kind: Exclude, message: string) { + super(message) + this.kind = kind + } +} + +const DDL = ` +CREATE TABLE pc_schema_stamp ( + stamp TEXT PRIMARY KEY NOT NULL +); +CREATE TABLE pc_source ( + source_kind TEXT NOT NULL, + source_id TEXT NOT NULL, + materialization TEXT NOT NULL, + description TEXT, + PRIMARY KEY (source_kind, source_id) +); +CREATE TABLE pc_artifact_version ( + family TEXT NOT NULL, + artifact_id TEXT NOT NULL, + revision INTEGER NOT NULL, + content_json TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + artifact_refs_json TEXT NOT NULL, + PRIMARY KEY (family, artifact_id, revision) +); +CREATE TABLE pc_artifact_head ( + family TEXT NOT NULL, + artifact_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (family, artifact_id), + FOREIGN KEY (family, artifact_id, revision) + REFERENCES pc_artifact_version(family, artifact_id, revision) +); +CREATE TABLE pc_memory_entry_version ( + scope_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + revision INTEGER NOT NULL, + kind TEXT NOT NULL, + text TEXT NOT NULL, + content_hash TEXT NOT NULL, + source_refs_json TEXT NOT NULL, + artifact_refs_json TEXT NOT NULL, + artifact_family TEXT NOT NULL CHECK (artifact_family = 'memory'), + artifact_id TEXT NOT NULL, + artifact_revision INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (scope_id, entry_id, revision), + UNIQUE (scope_id, content_hash), + FOREIGN KEY (artifact_family, artifact_id, artifact_revision) + REFERENCES pc_artifact_version(family, artifact_id, revision) +); +CREATE TABLE pc_memory_entry_head ( + scope_id TEXT NOT NULL, + entry_id TEXT NOT NULL, + revision INTEGER NOT NULL, + PRIMARY KEY (scope_id, entry_id), + FOREIGN KEY (scope_id, entry_id, revision) + REFERENCES pc_memory_entry_version(scope_id, entry_id, revision) +); +CREATE VIRTUAL TABLE pc_memory_entry_fts USING fts5( + scope_id UNINDEXED, + entry_id UNINDEXED, + searchable_text, + tokenize = "unicode61 tokenchars '_'" +); +` + +type SchemaRow = { readonly name: string; readonly type: string } +type StampRow = { readonly stamp: string } + +function listSchemaObjects(database: DatabaseSync): readonly SchemaRow[] { + const statement = database.prepare( + "SELECT name, type FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY name", + ) + statement.setReadBigInts(true) + return statement.all() as SchemaRow[] +} + +export function inspectSchemaGate(database: DatabaseSync): SchemaGateKind { + const objects = listSchemaObjects(database) + if (objects.length === 0) { + return EXPERIMENTAL_SCHEMA_KIND + } + const stampObject = objects.find((object) => object.name === 'pc_schema_stamp') + if (stampObject === undefined) { + const hasPowerContextTable = objects.some((object) => object.name.startsWith('pc_')) + throw new SchemaGateError( + hasPowerContextTable ? 'foreign-write-refused' : 'unrecognized-refused', + hasPowerContextTable + ? 'refusing writes to an unstamped PowerContext-like SQLite database' + : 'refusing writes to a non-empty unstamped SQLite database', + ) + } + const stamp = database.prepare('SELECT stamp FROM pc_schema_stamp').get() as + StampRow | undefined + if (stamp?.stamp !== EXPERIMENTAL_DATABASE_STAMP) { + throw new SchemaGateError( + 'foreign-write-refused', + 'refusing writes to a SQLite database with an unknown schema stamp', + ) + } + return EXPERIMENTAL_SCHEMA_KIND +} + +export function ensureExperimentalSchema( + database: DatabaseSync, + mayCreate = false, +): void { + const objects = listSchemaObjects(database) + if (objects.length === 0) { + if (!mayCreate) { + throw new SchemaGateError( + 'unrecognized-refused', + 'refusing writes to an existing unstamped SQLite database', + ) + } + database.exec(DDL) + database + .prepare('INSERT INTO pc_schema_stamp(stamp) VALUES (?)') + .run(EXPERIMENTAL_DATABASE_STAMP) + return + } + inspectSchemaGate(database) +} + +export function experimentalSchemaDdl(): string { + return DDL +} diff --git a/packages/builtin/src/persistence/source-store.ts b/packages/builtin/src/persistence/source-store.ts new file mode 100644 index 0000000..b6e3ff6 --- /dev/null +++ b/packages/builtin/src/persistence/source-store.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + SourceConflictError, + SourceNotFoundError, + createSource, + sourcesEqual, +} from '@powercontext/core' +import type { Source, SourceCatalogBackend, SourceStore } from '@powercontext/core' +import type { SQLiteSession } from './sqlite-session.js' + +type SourceRow = { + readonly source_kind: string + readonly source_id: string + readonly materialization: string + readonly description: string | null +} + +function normalizedSource(source: Source): Source { + return createSource({ + name: source.name.normalize('NFC'), + sourceKind: source.sourceKind.normalize('NFC'), + materialization: source.materialization, + ...(source.description === undefined + ? {} + : { description: source.description.normalize('NFC') }), + }) +} + +function fromRow(row: SourceRow): Source { + return createSource({ + name: row.source_id, + sourceKind: row.source_kind, + materialization: row.materialization as Source['materialization'], + ...(row.description === null ? {} : { description: row.description }), + }) +} + +export class SQLiteSourceStore implements SourceCatalogBackend, SourceStore { + constructor(private readonly session: SQLiteSession) {} + + async add(source: TSource): Promise { + const stored = normalizedSource(source) as TSource + try { + this.session + .prepare( + 'INSERT INTO pc_source(source_kind, source_id, materialization, description) VALUES (?, ?, ?, ?)', + ) + .run( + stored.sourceKind, + stored.name, + stored.materialization, + stored.description ?? null, + ) + } catch (error) { + if (error instanceof Error && /UNIQUE|PRIMARY KEY/i.test(error.message)) { + const existing = await this.get(stored) + if (sourcesEqual(existing, stored)) { + return existing as TSource + } + throw new SourceConflictError('source', `${stored.sourceKind}:${stored.name}`) + } + throw error + } + return stored + } + + async get(source: Source): Promise { + const normalized = normalizedSource(source) + const row = this.session + .prepare( + 'SELECT source_kind, source_id, materialization, description FROM pc_source WHERE source_kind = ? AND source_id = ?', + ) + .get(normalized.sourceKind, normalized.name) as SourceRow | undefined + if (row === undefined) { + throw new SourceNotFoundError(source) + } + return fromRow(row) + } + + async list(): Promise { + const rows = this.session + .prepare( + 'SELECT source_kind, source_id, materialization, description FROM pc_source ORDER BY source_kind, source_id', + ) + .all() as SourceRow[] + return rows.map(fromRow) + } +} diff --git a/packages/builtin/src/persistence/sqlite-session.ts b/packages/builtin/src/persistence/sqlite-session.ts new file mode 100644 index 0000000..d173aab --- /dev/null +++ b/packages/builtin/src/persistence/sqlite-session.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DatabaseSync } from 'node:sqlite' +import { existsSync, realpathSync } from 'node:fs' +import { basename, dirname, resolve } from 'node:path' +import { LifecycleError } from '@powercontext/core' +import { bigintToSafeInteger } from '@powercontext/core' +import { ensureExperimentalSchema } from './schema-gate.js' + +type SqliteStatement = ReturnType + +const writerPaths = new Set() + +function normalizedPath(path: string): string { + if (path === ':memory:') { + return path + } + const absolutePath = resolve(path) + if (existsSync(absolutePath)) { + return realpathSync.native(absolutePath) + } + return resolve(realpathSync.native(dirname(absolutePath)), basename(absolutePath)) +} + +export class SQLiteSession { + readonly path: string + readonly database: DatabaseSync + private closed = false + + private constructor(path: string, database: DatabaseSync) { + this.path = path + this.database = database + } + + static open(path: string): SQLiteSession { + const key = normalizedPath(path) + const mayCreate = path === ':memory:' || !existsSync(path) + if (key !== ':memory:' && writerPaths.has(key)) { + throw new LifecycleError( + `SQLite database already has an exclusive writer: ${path}`, + ) + } + const database = new DatabaseSync(path) + try { + database.exec('PRAGMA foreign_keys = ON') + database.exec('PRAGMA busy_timeout = 5000') + ensureExperimentalSchema(database, mayCreate) + if (path !== ':memory:') { + const journal = database.prepare('PRAGMA journal_mode = WAL').get() as + { readonly journal_mode?: unknown } | undefined + if (String(journal?.journal_mode ?? '').toLowerCase() !== 'wal') { + throw new LifecycleError('SQLite database did not enter WAL mode') + } + } + if (key !== ':memory:') { + writerPaths.add(key) + } + return new SQLiteSession(path, database) + } catch (error) { + database.close() + throw error + } + } + + assertOpen(): void { + if (this.closed) { + throw new LifecycleError('SQLite session is closed') + } + } + + exec(sql: string): void { + this.assertOpen() + this.database.exec(sql) + } + + prepare(sql: string): SqliteStatement { + this.assertOpen() + const statement = this.database.prepare(sql) + statement.setReadBigInts(true) + return statement + } + + safeInteger(value: bigint, field: string): number { + return bigintToSafeInteger(value, field) + } + + transaction(operation: () => T): T { + this.assertOpen() + this.database.exec('BEGIN IMMEDIATE') + try { + const result = operation() + this.database.exec('COMMIT') + return result + } catch (error) { + try { + this.database.exec('ROLLBACK') + } catch { + // Preserve the original transaction error. + } + throw error + } + } + + close(): void { + if (this.closed) { + return + } + this.closed = true + const key = normalizedPath(this.path) + if (key !== ':memory:') { + writerPaths.delete(key) + } + this.database.close() + } +} + +export function openSQLiteSession(path: string): SQLiteSession { + return SQLiteSession.open(path) +} diff --git a/packages/builtin/src/runtime.ts b/packages/builtin/src/runtime.ts new file mode 100644 index 0000000..60a6ce4 --- /dev/null +++ b/packages/builtin/src/runtime.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Source } from '@powercontext/core' +import { SQLiteArtifactStore } from './persistence/artifact-store.js' +import { + SQLiteMemoryStore, + type MemorySearchInput, + type RememberInput, +} from './persistence/memory-store.js' +import { openSQLiteSession } from './persistence/sqlite-session.js' +import { SQLiteSourceStore } from './persistence/source-store.js' + +export class ExperimentalRuntime { + readonly sources: SQLiteSourceStore + readonly artifacts: SQLiteArtifactStore + readonly memory: SQLiteMemoryStore + private readonly session + + constructor(path: string) { + this.session = openSQLiteSession(path) + this.sources = new SQLiteSourceStore(this.session) + this.artifacts = new SQLiteArtifactStore(this.session) + this.memory = new SQLiteMemoryStore(this.session) + } + + capture(source: Source) { + return this.sources.add(source) + } + + remember(input: RememberInput) { + return this.memory.remember(input) + } + + list(scopeId?: string) { + return this.memory.listEntries(scopeId) + } + + get(scopeId: string, entryId: string) { + return this.memory.getEntry(scopeId, entryId) + } + + search(input: MemorySearchInput) { + return this.memory.search(input) + } + + close(): void { + this.session.close() + } +} + +export async function openExperimentalRuntime(options: { + readonly path: string +}): Promise { + return new ExperimentalRuntime(options.path) +} diff --git a/packages/builtin/tests/artifact-store.test.ts b/packages/builtin/tests/artifact-store.test.ts new file mode 100644 index 0000000..072d2bc --- /dev/null +++ b/packages/builtin/tests/artifact-store.test.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { RevisionConflictError, createArtifactDraft } from '@powercontext/core' +import { describe, expect, it } from 'vitest' +import { SQLiteArtifactStore, openSQLiteSession } from '../src/index.js' + +describe('SQLiteArtifactStore', () => { + it('persists immutable revisions and rejects stale CAS writers', async () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-artifact-')) + const path = join(directory, 'runtime.sqlite3') + const first = openSQLiteSession(path) + const store = new SQLiteArtifactStore(first) + const created = await store.create( + 'artifact-1', + createArtifactDraft({ family: 'memory', content: { text: 'v1' } }), + ) + const revised = await store.revise( + created, + createArtifactDraft({ family: 'memory', content: { text: 'v2' } }), + ) + await expect( + store.revise( + created, + createArtifactDraft({ family: 'memory', content: { text: 'stale' } }), + ), + ).rejects.toThrow(RevisionConflictError) + first.close() + const second = openSQLiteSession(path) + try { + const reopened = new SQLiteArtifactStore(second) + await expect(reopened.latest('memory', 'artifact-1')).resolves.toMatchObject({ + family: revised.family, + artifactId: revised.artifactId, + revision: revised.revision, + content: revised.content, + lineage: revised.lineage, + }) + await expect(reopened.revisions('memory', 'artifact-1')).resolves.toHaveLength(2) + } finally { + second.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/builtin/tests/memory-store.test.ts b/packages/builtin/tests/memory-store.test.ts new file mode 100644 index 0000000..c529bb8 --- /dev/null +++ b/packages/builtin/tests/memory-store.test.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { + ENTRY_CONTENT_HASH_DOMAIN, + UnavailableError, + analyzeText, + hashDomain, +} from '@powercontext/core' +import { describe, expect, it } from 'vitest' +import { openExperimentalRuntime } from '../src/index.js' + +describe('experimental Memory + FTS', () => { + it('hashes canonically, persists, and finds Analyzer-projected CJK after reopen', async () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-memory-')) + const path = join(directory, 'runtime.sqlite3') + const first = await openExperimentalRuntime({ path }) + const remembered = await first.remember({ + scope_id: 'scope-1', + kind: 'note', + text: '中文 café', + }) + expect(remembered.content_hash).toBe( + hashDomain(ENTRY_CONTENT_HASH_DOMAIN, { + kind: 'note', + text: '中文 café', + source_refs: [], + artifact_refs: [], + }), + ) + first.close() + const inspection = new DatabaseSync(path, { readOnly: true }) + const projection = inspection + .prepare('SELECT searchable_text FROM pc_memory_entry_fts WHERE entry_id = ?') + .get(remembered.entry_id) as { searchable_text: string } + inspection.close() + expect(projection.searchable_text).toBe(analyzeText(remembered.text)) + + const second = await openExperimentalRuntime({ path }) + try { + await expect(second.list('scope-1')).resolves.toHaveLength(1) + await expect(second.get('scope-1', remembered.entry_id)).resolves.toEqual( + remembered, + ) + await expect( + second.search({ scope_id: 'scope-1', query: '中文' }), + ).resolves.toEqual([remembered]) + await expect( + second.search({ scope_id: 'scope-1', query: 'cafe\u0301' }), + ).resolves.toEqual([remembered]) + await expect( + second.search({ scope_id: 'scope-1', query: '中文', mode: 'vector' }), + ).rejects.toThrow(UnavailableError) + await expect( + second.search({ scope_id: 'scope-1', query: '中文', mode: 'hybrid' }), + ).rejects.toThrow(UnavailableError) + } finally { + second.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/builtin/tests/schema-gate.test.ts b/packages/builtin/tests/schema-gate.test.ts new file mode 100644 index 0000000..af716a0 --- /dev/null +++ b/packages/builtin/tests/schema-gate.test.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { describe, expect, it } from 'vitest' +import { EXPERIMENTAL_DATABASE_STAMP, openSQLiteSession } from '../src/index.js' +import type { SchemaGateError } from '../src/index.js' + +describe('experimental schema gate', () => { + it('stamps a newly created database', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-schema-')) + const path = join(directory, 'runtime.sqlite3') + const session = openSQLiteSession(path) + try { + expect(session.prepare('SELECT stamp FROM pc_schema_stamp').get()).toEqual({ + stamp: EXPERIMENTAL_DATABASE_STAMP, + }) + } finally { + session.close() + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('refuses an unstamped pc-like database without changing its bytes', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-foreign-')) + const path = join(directory, 'foreign.sqlite3') + const foreign = new DatabaseSync(path) + foreign.exec('CREATE TABLE pc_memory_entry_foreign(value TEXT)') + foreign.close() + const before = readFileSync(path) + try { + expect(() => openSQLiteSession(path)).toThrowError( + expect.objectContaining>({ + kind: 'foreign-write-refused', + }), + ) + expect(readFileSync(path)).toEqual(before) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('refuses an unknown experimental stamp', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-unknown-')) + const path = join(directory, 'unknown.sqlite3') + const foreign = new DatabaseSync(path) + foreign.exec('CREATE TABLE pc_schema_stamp(stamp TEXT PRIMARY KEY)') + foreign.prepare('INSERT INTO pc_schema_stamp(stamp) VALUES (?)').run('foreign') + foreign.close() + try { + expect(() => openSQLiteSession(path)).toThrowError( + expect.objectContaining>({ + kind: 'foreign-write-refused', + }), + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/builtin/tests/source-store.test.ts b/packages/builtin/tests/source-store.test.ts new file mode 100644 index 0000000..db485ca --- /dev/null +++ b/packages/builtin/tests/source-store.test.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createSource } from '@powercontext/core' +import { describe, expect, it } from 'vitest' +import { SQLiteSourceStore, openSQLiteSession } from '../src/index.js' + +describe('SQLiteSourceStore', () => { + it('persists NFC-normalized sources across reopen', async () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-source-')) + const path = join(directory, 'runtime.sqlite3') + const source = createSource({ + name: 'cafe\u0301', + sourceKind: 'manual', + materialization: 'captured', + description: 'decomposed', + }) + const first = openSQLiteSession(path) + const stored = await new SQLiteSourceStore(first).add(source) + expect(stored.name).toBe('café') + first.close() + const second = openSQLiteSession(path) + try { + await expect(new SQLiteSourceStore(second).get(stored)).resolves.toEqual(stored) + } finally { + second.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/builtin/tests/sqlite-session.test.ts b/packages/builtin/tests/sqlite-session.test.ts new file mode 100644 index 0000000..eddd0af --- /dev/null +++ b/packages/builtin/tests/sqlite-session.test.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + JS_MAX_SAFE_INTEGER, + LifecycleError, + SafeIntegerError, +} from '@powercontext/core' +import { openSQLiteSession } from '../src/index.js' + +describe('SQLiteSession', () => { + it('uses WAL, foreign keys, and a 5000ms busy timeout', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-session-')) + const session = openSQLiteSession(join(directory, 'runtime.sqlite3')) + try { + expect(session.prepare('PRAGMA journal_mode').get()).toMatchObject({ + journal_mode: 'wal', + }) + expect(session.prepare('PRAGMA foreign_keys').get()).toMatchObject({ + foreign_keys: 1n, + }) + expect(session.prepare('PRAGMA busy_timeout').get()).toMatchObject({ + timeout: 5000n, + }) + } finally { + session.close() + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('fails closed when an INTEGER is outside the JSON-safe range', () => { + const session = openSQLiteSession(':memory:') + try { + session.exec('CREATE TABLE unsafe_integer(value INTEGER NOT NULL)') + session + .prepare('INSERT INTO unsafe_integer(value) VALUES (?)') + .run(BigInt(JS_MAX_SAFE_INTEGER) + 1n) + const row = session.prepare('SELECT value FROM unsafe_integer').get() as { + value: bigint + } + expect(() => session.safeInteger(row.value, 'value')).toThrow(SafeIntegerError) + } finally { + session.close() + } + }) + + it('rejects close-then-write and a queued write after close', async () => { + const session = openSQLiteSession(':memory:') + session.close() + expect(() => session.exec('CREATE TABLE late_write(value TEXT)')).toThrow( + LifecycleError, + ) + await expect( + Promise.resolve().then(() => session.prepare('SELECT 1')), + ).rejects.toThrow(LifecycleError) + }) + + it('allows only one writer session per file', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-writer-')) + const path = join(directory, 'runtime.sqlite3') + const first = openSQLiteSession(path) + try { + expect(() => openSQLiteSession(path)).toThrow(/exclusive writer/) + } finally { + first.close() + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('treats relative and absolute aliases as the same writer path', () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-writer-alias-')) + const absolutePath = join(directory, 'runtime.sqlite3') + const relativePath = relative(process.cwd(), absolutePath) + const first = openSQLiteSession(absolutePath) + try { + expect(() => openSQLiteSession(relativePath)).toThrow(/exclusive writer/) + } finally { + first.close() + rmSync(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/src/canonical/analyzer.ts b/packages/core/src/canonical/analyzer.ts new file mode 100644 index 0000000..c38e872 --- /dev/null +++ b/packages/core/src/canonical/analyzer.ts @@ -0,0 +1,419 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const ANALYZER_ID = 'powercontext.analyzer.v1' as const + +const WORD_CHARACTER = /^[\p{L}\p{N}_]$/u + +function caseFoldCharacter(character: string): string { + const pythonOverride = PYTHON_CASE_FOLD_OVERRIDES.get(character) + if (pythonOverride !== undefined) { + return pythonOverride + } + const lowered = character.toLowerCase() + return lowered === 'ς' ? 'σ' : lowered +} + +function caseFold(value: string): string { + let folded = '' + for (const character of value) { + folded += caseFoldCharacter(character) + } + return folded +} + +function isCjkCodePoint(codePoint: number): boolean { + return ( + (codePoint >= 0x3400 && codePoint <= 0x4dbf) || + (codePoint >= 0x4e00 && codePoint <= 0x9fff) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0x20000 && codePoint <= 0x2fa1f) + ) +} + +function appendCjkTerms(terms: string[], codePoints: readonly number[]): void { + for (const codePoint of codePoints) { + terms.push(`u_${codePoint.toString(16)}`) + } + for (let index = 1; index < codePoints.length; index += 1) { + const left = codePoints[index - 1] + const right = codePoints[index] + if (left !== undefined && right !== undefined) { + terms.push(`b_${left.toString(16)}_${right.toString(16)}`) + } + } +} + +export function analyzeText(value: string): string { + const terms: string[] = [] + let word = '' + let cjkCodePoints: number[] = [] + + const flushWord = (): void => { + if (word.length > 0) { + terms.push(word) + word = '' + } + } + const flushCjk = (): void => { + appendCjkTerms(terms, cjkCodePoints) + cjkCodePoints = [] + } + + for (const character of caseFold(value.normalize('NFC'))) { + const codePoint = character.codePointAt(0) + if (codePoint !== undefined && isCjkCodePoint(codePoint)) { + flushWord() + cjkCodePoints.push(codePoint) + } else if (WORD_CHARACTER.test(character)) { + flushCjk() + word += character + } else { + flushWord() + flushCjk() + } + } + flushWord() + flushCjk() + return terms.join(' ') +} + +export function ftsMatchQuery(query: string): string | null { + const analyzed = analyzeText(query) + if (analyzed.length === 0) { + return null + } + return analyzed + .split(' ') + .map((term) => `"${term.replaceAll('"', '""')}"`) + .join(' OR ') +} + +export function admitsFtsText(query: string, text: string): boolean { + const queryTerms = new Set(analyzeText(query).split(' ').filter(Boolean)) + if (queryTerms.size === 0) { + return false + } + const textTerms = new Set(analyzeText(text).split(' ').filter(Boolean)) + let intersection = 0 + for (const term of queryTerms) { + if (textTerms.has(term)) { + intersection += 1 + } + } + const required = + queryTerms.size <= 2 ? 1 : Math.max(2, Math.ceil(queryTerms.size * 0.25)) + return intersection >= required +} +const PYTHON_CASE_FOLD_OVERRIDES = new Map([ + ['\u00b5', '\u03bc'], + ['\u00df', 'ss'], + ['\u0149', '\u02bcn'], + ['\u017f', 's'], + ['\u01f0', 'j\u030c'], + ['\u0345', '\u03b9'], + ['\u0390', '\u03b9\u0308\u0301'], + ['\u03b0', '\u03c5\u0308\u0301'], + ['\u03c2', '\u03c3'], + ['\u03d0', '\u03b2'], + ['\u03d1', '\u03b8'], + ['\u03d5', '\u03c6'], + ['\u03d6', '\u03c0'], + ['\u03f0', '\u03ba'], + ['\u03f1', '\u03c1'], + ['\u03f5', '\u03b5'], + ['\u0587', '\u0565\u0582'], + ['\u13a0', '\u13a0'], + ['\u13a1', '\u13a1'], + ['\u13a2', '\u13a2'], + ['\u13a3', '\u13a3'], + ['\u13a4', '\u13a4'], + ['\u13a5', '\u13a5'], + ['\u13a6', '\u13a6'], + ['\u13a7', '\u13a7'], + ['\u13a8', '\u13a8'], + ['\u13a9', '\u13a9'], + ['\u13aa', '\u13aa'], + ['\u13ab', '\u13ab'], + ['\u13ac', '\u13ac'], + ['\u13ad', '\u13ad'], + ['\u13ae', '\u13ae'], + ['\u13af', '\u13af'], + ['\u13b0', '\u13b0'], + ['\u13b1', '\u13b1'], + ['\u13b2', '\u13b2'], + ['\u13b3', '\u13b3'], + ['\u13b4', '\u13b4'], + ['\u13b5', '\u13b5'], + ['\u13b6', '\u13b6'], + ['\u13b7', '\u13b7'], + ['\u13b8', '\u13b8'], + ['\u13b9', '\u13b9'], + ['\u13ba', '\u13ba'], + ['\u13bb', '\u13bb'], + ['\u13bc', '\u13bc'], + ['\u13bd', '\u13bd'], + ['\u13be', '\u13be'], + ['\u13bf', '\u13bf'], + ['\u13c0', '\u13c0'], + ['\u13c1', '\u13c1'], + ['\u13c2', '\u13c2'], + ['\u13c3', '\u13c3'], + ['\u13c4', '\u13c4'], + ['\u13c5', '\u13c5'], + ['\u13c6', '\u13c6'], + ['\u13c7', '\u13c7'], + ['\u13c8', '\u13c8'], + ['\u13c9', '\u13c9'], + ['\u13ca', '\u13ca'], + ['\u13cb', '\u13cb'], + ['\u13cc', '\u13cc'], + ['\u13cd', '\u13cd'], + ['\u13ce', '\u13ce'], + ['\u13cf', '\u13cf'], + ['\u13d0', '\u13d0'], + ['\u13d1', '\u13d1'], + ['\u13d2', '\u13d2'], + ['\u13d3', '\u13d3'], + ['\u13d4', '\u13d4'], + ['\u13d5', '\u13d5'], + ['\u13d6', '\u13d6'], + ['\u13d7', '\u13d7'], + ['\u13d8', '\u13d8'], + ['\u13d9', '\u13d9'], + ['\u13da', '\u13da'], + ['\u13db', '\u13db'], + ['\u13dc', '\u13dc'], + ['\u13dd', '\u13dd'], + ['\u13de', '\u13de'], + ['\u13df', '\u13df'], + ['\u13e0', '\u13e0'], + ['\u13e1', '\u13e1'], + ['\u13e2', '\u13e2'], + ['\u13e3', '\u13e3'], + ['\u13e4', '\u13e4'], + ['\u13e5', '\u13e5'], + ['\u13e6', '\u13e6'], + ['\u13e7', '\u13e7'], + ['\u13e8', '\u13e8'], + ['\u13e9', '\u13e9'], + ['\u13ea', '\u13ea'], + ['\u13eb', '\u13eb'], + ['\u13ec', '\u13ec'], + ['\u13ed', '\u13ed'], + ['\u13ee', '\u13ee'], + ['\u13ef', '\u13ef'], + ['\u13f0', '\u13f0'], + ['\u13f1', '\u13f1'], + ['\u13f2', '\u13f2'], + ['\u13f3', '\u13f3'], + ['\u13f4', '\u13f4'], + ['\u13f5', '\u13f5'], + ['\u13f8', '\u13f0'], + ['\u13f9', '\u13f1'], + ['\u13fa', '\u13f2'], + ['\u13fb', '\u13f3'], + ['\u13fc', '\u13f4'], + ['\u13fd', '\u13f5'], + ['\u1c80', '\u0432'], + ['\u1c81', '\u0434'], + ['\u1c82', '\u043e'], + ['\u1c83', '\u0441'], + ['\u1c84', '\u0442'], + ['\u1c85', '\u0442'], + ['\u1c86', '\u044a'], + ['\u1c87', '\u0463'], + ['\u1c88', '\ua64b'], + ['\u1e96', 'h\u0331'], + ['\u1e97', 't\u0308'], + ['\u1e98', 'w\u030a'], + ['\u1e99', 'y\u030a'], + ['\u1e9a', 'a\u02be'], + ['\u1e9b', '\u1e61'], + ['\u1e9e', 'ss'], + ['\u1f50', '\u03c5\u0313'], + ['\u1f52', '\u03c5\u0313\u0300'], + ['\u1f54', '\u03c5\u0313\u0301'], + ['\u1f56', '\u03c5\u0313\u0342'], + ['\u1f80', '\u1f00\u03b9'], + ['\u1f81', '\u1f01\u03b9'], + ['\u1f82', '\u1f02\u03b9'], + ['\u1f83', '\u1f03\u03b9'], + ['\u1f84', '\u1f04\u03b9'], + ['\u1f85', '\u1f05\u03b9'], + ['\u1f86', '\u1f06\u03b9'], + ['\u1f87', '\u1f07\u03b9'], + ['\u1f88', '\u1f00\u03b9'], + ['\u1f89', '\u1f01\u03b9'], + ['\u1f8a', '\u1f02\u03b9'], + ['\u1f8b', '\u1f03\u03b9'], + ['\u1f8c', '\u1f04\u03b9'], + ['\u1f8d', '\u1f05\u03b9'], + ['\u1f8e', '\u1f06\u03b9'], + ['\u1f8f', '\u1f07\u03b9'], + ['\u1f90', '\u1f20\u03b9'], + ['\u1f91', '\u1f21\u03b9'], + ['\u1f92', '\u1f22\u03b9'], + ['\u1f93', '\u1f23\u03b9'], + ['\u1f94', '\u1f24\u03b9'], + ['\u1f95', '\u1f25\u03b9'], + ['\u1f96', '\u1f26\u03b9'], + ['\u1f97', '\u1f27\u03b9'], + ['\u1f98', '\u1f20\u03b9'], + ['\u1f99', '\u1f21\u03b9'], + ['\u1f9a', '\u1f22\u03b9'], + ['\u1f9b', '\u1f23\u03b9'], + ['\u1f9c', '\u1f24\u03b9'], + ['\u1f9d', '\u1f25\u03b9'], + ['\u1f9e', '\u1f26\u03b9'], + ['\u1f9f', '\u1f27\u03b9'], + ['\u1fa0', '\u1f60\u03b9'], + ['\u1fa1', '\u1f61\u03b9'], + ['\u1fa2', '\u1f62\u03b9'], + ['\u1fa3', '\u1f63\u03b9'], + ['\u1fa4', '\u1f64\u03b9'], + ['\u1fa5', '\u1f65\u03b9'], + ['\u1fa6', '\u1f66\u03b9'], + ['\u1fa7', '\u1f67\u03b9'], + ['\u1fa8', '\u1f60\u03b9'], + ['\u1fa9', '\u1f61\u03b9'], + ['\u1faa', '\u1f62\u03b9'], + ['\u1fab', '\u1f63\u03b9'], + ['\u1fac', '\u1f64\u03b9'], + ['\u1fad', '\u1f65\u03b9'], + ['\u1fae', '\u1f66\u03b9'], + ['\u1faf', '\u1f67\u03b9'], + ['\u1fb2', '\u1f70\u03b9'], + ['\u1fb3', '\u03b1\u03b9'], + ['\u1fb4', '\u03ac\u03b9'], + ['\u1fb6', '\u03b1\u0342'], + ['\u1fb7', '\u03b1\u0342\u03b9'], + ['\u1fbc', '\u03b1\u03b9'], + ['\u1fbe', '\u03b9'], + ['\u1fc2', '\u1f74\u03b9'], + ['\u1fc3', '\u03b7\u03b9'], + ['\u1fc4', '\u03ae\u03b9'], + ['\u1fc6', '\u03b7\u0342'], + ['\u1fc7', '\u03b7\u0342\u03b9'], + ['\u1fcc', '\u03b7\u03b9'], + ['\u1fd2', '\u03b9\u0308\u0300'], + ['\u1fd3', '\u03b9\u0308\u0301'], + ['\u1fd6', '\u03b9\u0342'], + ['\u1fd7', '\u03b9\u0308\u0342'], + ['\u1fe2', '\u03c5\u0308\u0300'], + ['\u1fe3', '\u03c5\u0308\u0301'], + ['\u1fe4', '\u03c1\u0313'], + ['\u1fe6', '\u03c5\u0342'], + ['\u1fe7', '\u03c5\u0308\u0342'], + ['\u1ff2', '\u1f7c\u03b9'], + ['\u1ff3', '\u03c9\u03b9'], + ['\u1ff4', '\u03ce\u03b9'], + ['\u1ff6', '\u03c9\u0342'], + ['\u1ff7', '\u03c9\u0342\u03b9'], + ['\u1ffc', '\u03c9\u03b9'], + ['\uab70', '\u13a0'], + ['\uab71', '\u13a1'], + ['\uab72', '\u13a2'], + ['\uab73', '\u13a3'], + ['\uab74', '\u13a4'], + ['\uab75', '\u13a5'], + ['\uab76', '\u13a6'], + ['\uab77', '\u13a7'], + ['\uab78', '\u13a8'], + ['\uab79', '\u13a9'], + ['\uab7a', '\u13aa'], + ['\uab7b', '\u13ab'], + ['\uab7c', '\u13ac'], + ['\uab7d', '\u13ad'], + ['\uab7e', '\u13ae'], + ['\uab7f', '\u13af'], + ['\uab80', '\u13b0'], + ['\uab81', '\u13b1'], + ['\uab82', '\u13b2'], + ['\uab83', '\u13b3'], + ['\uab84', '\u13b4'], + ['\uab85', '\u13b5'], + ['\uab86', '\u13b6'], + ['\uab87', '\u13b7'], + ['\uab88', '\u13b8'], + ['\uab89', '\u13b9'], + ['\uab8a', '\u13ba'], + ['\uab8b', '\u13bb'], + ['\uab8c', '\u13bc'], + ['\uab8d', '\u13bd'], + ['\uab8e', '\u13be'], + ['\uab8f', '\u13bf'], + ['\uab90', '\u13c0'], + ['\uab91', '\u13c1'], + ['\uab92', '\u13c2'], + ['\uab93', '\u13c3'], + ['\uab94', '\u13c4'], + ['\uab95', '\u13c5'], + ['\uab96', '\u13c6'], + ['\uab97', '\u13c7'], + ['\uab98', '\u13c8'], + ['\uab99', '\u13c9'], + ['\uab9a', '\u13ca'], + ['\uab9b', '\u13cb'], + ['\uab9c', '\u13cc'], + ['\uab9d', '\u13cd'], + ['\uab9e', '\u13ce'], + ['\uab9f', '\u13cf'], + ['\uaba0', '\u13d0'], + ['\uaba1', '\u13d1'], + ['\uaba2', '\u13d2'], + ['\uaba3', '\u13d3'], + ['\uaba4', '\u13d4'], + ['\uaba5', '\u13d5'], + ['\uaba6', '\u13d6'], + ['\uaba7', '\u13d7'], + ['\uaba8', '\u13d8'], + ['\uaba9', '\u13d9'], + ['\uabaa', '\u13da'], + ['\uabab', '\u13db'], + ['\uabac', '\u13dc'], + ['\uabad', '\u13dd'], + ['\uabae', '\u13de'], + ['\uabaf', '\u13df'], + ['\uabb0', '\u13e0'], + ['\uabb1', '\u13e1'], + ['\uabb2', '\u13e2'], + ['\uabb3', '\u13e3'], + ['\uabb4', '\u13e4'], + ['\uabb5', '\u13e5'], + ['\uabb6', '\u13e6'], + ['\uabb7', '\u13e7'], + ['\uabb8', '\u13e8'], + ['\uabb9', '\u13e9'], + ['\uabba', '\u13ea'], + ['\uabbb', '\u13eb'], + ['\uabbc', '\u13ec'], + ['\uabbd', '\u13ed'], + ['\uabbe', '\u13ee'], + ['\uabbf', '\u13ef'], + ['\ufb00', 'ff'], + ['\ufb01', 'fi'], + ['\ufb02', 'fl'], + ['\ufb03', 'ffi'], + ['\ufb04', 'ffl'], + ['\ufb05', 'st'], + ['\ufb06', 'st'], + ['\ufb13', '\u0574\u0576'], + ['\ufb14', '\u0574\u0565'], + ['\ufb15', '\u0574\u056b'], + ['\ufb16', '\u057e\u0576'], + ['\ufb17', '\u0574\u056d'], +]) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0778419..53dce24 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,6 +61,12 @@ export { isSafeInteger, } from './integers.js' export { canonicalizeJson, canonicalizeJsonBytes } from './canonical/jcs.js' +export { + ANALYZER_ID, + admitsFtsText, + analyzeText, + ftsMatchQuery, +} from './canonical/analyzer.js' export { canonicalizeDomain, canonicalizeDomainBytes, diff --git a/packages/core/tests/analyzer-oracle.test.ts b/packages/core/tests/analyzer-oracle.test.ts new file mode 100644 index 0000000..9701faf --- /dev/null +++ b/packages/core/tests/analyzer-oracle.test.ts @@ -0,0 +1,164 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { existsSync, readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { admitsFtsText, analyzeText, ftsMatchQuery } from '../src/index.js' + +const PINNED_COMMIT = '733e4bf6b378785e76274ff07632029c699ecb09' + +interface AnalyzeCase { + readonly operation: 'analyze_text' | 'fts_match_query' + readonly value: string +} + +interface AdmitsCase { + readonly operation: 'admits_fts_text' + readonly query: string + readonly text: string +} + +type OracleCase = AnalyzeCase | AdmitsCase + +function pythonRoot(): string { + const root = process.env['POWERCONTEXT_PYTHON_ROOT'] + if (root === undefined || root.length === 0) { + throw new Error( + 'POWERCONTEXT_PYTHON_ROOT must point at the pinned powercontext checkout', + ) + } + if (!existsSync(join(root, 'pyproject.toml'))) { + throw new Error(`Python oracle root has no pyproject.toml: ${root}`) + } + const project = readFileSync(join(root, 'pyproject.toml'), 'utf8') + if (!/^name\s*=\s*["']powercontext["']/m.test(project)) { + throw new Error( + `Python oracle root is not the powercontext project (PowerMem is not allowed): ${root}`, + ) + } + const revision = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }) + if (revision.status !== 0 || revision.stdout.trim() !== PINNED_COMMIT) { + throw new Error( + `Python oracle root must be checked out at ${PINNED_COMMIT}; got ${revision.stdout.trim() || revision.stderr.trim()}`, + ) + } + return root +} + +function pythonExecutable(root: string): string { + const configured = process.env['POWERCONTEXT_PYTHON'] + if (configured !== undefined && configured.length > 0) { + return configured + } + const virtualEnvironment = join(root, '.venv', 'bin', 'python') + return existsSync(virtualEnvironment) ? virtualEnvironment : 'python3' +} + +function runPythonOracle( + root: string, + cases: readonly OracleCase[], +): readonly unknown[] { + const script = ` +import json +import importlib.util +import sys + +module_path = sys.argv[1] + "/src/powercontext/builtin/artifacts/search.py" +spec = importlib.util.spec_from_file_location("pinned_search", module_path) +if spec is None or spec.loader is None: + raise RuntimeError("could not load pinned Analyzer module") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +admits_fts_text = module.admits_fts_text +analyze_text = module.analyze_text +fts_match_query = module.fts_match_query + +cases = json.load(sys.stdin) +results = [] +for case in cases: + if case["operation"] == "analyze_text": + results.append(analyze_text(case["value"])) + elif case["operation"] == "fts_match_query": + results.append(fts_match_query(case["value"])) + else: + results.append(admits_fts_text(case["query"], case["text"])) +json.dump(results, sys.stdout, ensure_ascii=False) +` + const result = spawnSync(pythonExecutable(root), ['-c', script, root], { + input: JSON.stringify(cases), + encoding: 'utf8', + }) + if (result.error !== undefined || result.status !== 0) { + throw new Error( + `Python Analyzer oracle failed: ${result.error?.message ?? result.stderr.trim()}`, + ) + } + return JSON.parse(result.stdout) as readonly unknown[] +} + +const ANALYZE_CASES: readonly OracleCase[] = [ + { operation: 'analyze_text', value: 'Hello_World' }, + { operation: 'analyze_text', value: '中文' }, + { operation: 'analyze_text', value: 'Hello中文World' }, + { operation: 'analyze_text', value: 'CAFÉ' }, + { operation: 'analyze_text', value: 'cafe\u0301' }, + { operation: 'analyze_text', value: 'Straße ẞ' }, + { operation: 'analyze_text', value: '... 😄' }, + { operation: 'analyze_text', value: 'mixed 中文 and 日本語' }, + { operation: 'fts_match_query', value: 'Hello 中文' }, + { operation: 'fts_match_query', value: '... 😄' }, + { operation: 'admits_fts_text', query: 'one two', text: 'zero two' }, + { operation: 'admits_fts_text', query: 'one two three four', text: 'one only' }, + { operation: 'admits_fts_text', query: 'one two three four', text: 'one and four' }, + { + operation: 'admits_fts_text', + query: 'one two three four five six seven eight nine', + text: 'one two', + }, + { + operation: 'admits_fts_text', + query: 'one two three four five six seven eight nine', + text: 'one two nine', + }, +] + +function jsResult(testCase: OracleCase): unknown { + if (testCase.operation === 'analyze_text') { + return analyzeText(testCase.value) + } + if (testCase.operation === 'fts_match_query') { + return ftsMatchQuery(testCase.value) + } + if (testCase.operation === 'admits_fts_text') { + return admitsFtsText(testCase.query, testCase.text) + } + throw new Error(`unsupported Analyzer oracle operation: ${testCase.operation}`) +} + +describe('Analyzer v1 pinned Python oracle', () => { + it.skipIf(process.env['POWERCONTEXT_SKELETON_ORACLE'] !== '1')( + 'matches analyze_text, fts_match_query, and admits_fts_text', + () => { + const root = pythonRoot() + const expected = runPythonOracle(root, ANALYZE_CASES) + expect(ANALYZE_CASES.map(jsResult)).toEqual(expected) + }, + ) +}) diff --git a/packages/core/tests/analyzer.test.ts b/packages/core/tests/analyzer.test.ts new file mode 100644 index 0000000..71840aa --- /dev/null +++ b/packages/core/tests/analyzer.test.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest' +import { ANALYZER_ID, admitsFtsText, analyzeText, ftsMatchQuery } from '../src/index.js' + +describe('Analyzer v1', () => { + it('keeps ASCII underscores inside case-folded words', () => { + expect(ANALYZER_ID).toBe('powercontext.analyzer.v1') + expect(analyzeText('Hello_World')).toBe('hello_world') + expect(analyzeText('Straße ẞ')).toBe('strasse ss') + expect(analyzeText('µ ς ϵ Ꭰ ꭰ')).toBe('μ σ ε Ꭰ Ꭰ') + }) + + it('emits CJK unigrams followed by adjacent bigrams', () => { + expect(analyzeText('中文')).toBe('u_4e2d u_6587 b_4e2d_6587') + }) + + it('preserves segment order when mixing Latin and CJK', () => { + expect(analyzeText('Hello中文World')).toBe('hello u_4e2d u_6587 b_4e2d_6587 world') + }) + + it('normalizes canonically equivalent input before tokenization', () => { + expect(analyzeText('CAFÉ')).toBe('café') + expect(analyzeText('cafe\u0301')).toBe('café') + }) + + it('returns no query for punctuation-only input', () => { + expect(analyzeText('... 😄')).toBe('') + expect(ftsMatchQuery('... 😄')).toBeNull() + expect(admitsFtsText('... 😄', 'anything')).toBe(false) + }) + + it('quotes analyzed terms for FTS MATCH', () => { + expect(ftsMatchQuery('Hello 中文')).toBe( + '"hello" OR "u_4e2d" OR "u_6587" OR "b_4e2d_6587"', + ) + }) + + it('uses one hit for short queries and the coverage gate for long queries', () => { + expect(admitsFtsText('one two', 'zero two')).toBe(true) + expect(admitsFtsText('one two three four', 'one only')).toBe(false) + expect(admitsFtsText('one two three four', 'one and four')).toBe(true) + expect( + admitsFtsText('one two three four five six seven eight nine', 'one two'), + ).toBe(false) + expect( + admitsFtsText('one two three four five six seven eight nine', 'one two nine'), + ).toBe(true) + }) +}) From 760e51c5b920b77e16b8bb7057269e78fe10bf1e Mon Sep 17 00:00:00 2001 From: guozhihao-224 Date: Sat, 29 Aug 2026 17:52:46 +0800 Subject: [PATCH 2/2] Add an experimental subset HTTP Server over the TypeScript runtime. Expose loopback Fastify health, capabilities, and Memory routes through protocol validators, without claiming C3 or faking capture/vector success. --- CHANGELOG.md | 6 + packages/server/README.md | 23 +- packages/server/package.json | 6 +- packages/server/src/index.ts | 439 ++++++++++++++++++++ packages/server/tests/package-info.test.ts | 18 +- packages/server/tests/subset-server.test.ts | 205 +++++++++ pnpm-lock.yaml | 6 + 7 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 packages/server/tests/subset-server.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a8c77..81fe3e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ are published. ### Added +- Experimental `@powercontext/server` subset HTTP composition over the + TypeScript-created database: health, capabilities, and minimal Memory + routes only. It is not the full Server, not a Python replacement, and not + C3; `sqlite-fts` is the intended M2 product-line profile, not a claim that + this package implements that profile or C3; unsupported capabilities return + structured errors instead of fake success. - Experimental `@powercontext/builtin` Node `node:sqlite` persistence skeleton: Analyzer v1-projected FTS, guarded schema stamp, Source/Artifact persistence, CAS, and minimal Memory remember/list/get/search. It is not C3 and does not diff --git a/packages/server/README.md b/packages/server/README.md index 4cba171..1f6aabf 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,3 +1,24 @@ # @powercontext/server -HTTP, MCP, Dashboard and observability composition. +Experimental subset HTTP Server for the TypeScript-created experimental +database. It exposes health, capabilities, Source-content availability, and +minimal Memory remember/search/list/get over Fastify, using the frozen +`@powercontext/protocol` validators and the existing `@powercontext/builtin` +runtime. + +`PACKAGE_PROFILE` remains `sqlite-fts` as the intended M2 product-line name, +but this package does not claim the `sqlite-fts` implementation or C3. This is +not the full 52-route Server and not a Python replacement. +MCP, CLI, Dashboard, extraction, handoff, vector, and hybrid behavior are not +registered as available. The server listens on `127.0.0.1` only. + +## Pits / intentional gaps + +- `capture_content_source` is registered and always returns `503`: HTTP CaptureContent + (202 + position) is not the `ExperimentalRuntime.capture()` catalog add, so the + server does not fake a `202`. +- Search hit `score` is the placeholder `1`, not BM25; it is not a ranking claim. +- `entry_id === content_hash`, `entry_version_id === entry_id`, and `version` is + `1`; this is not Python revision/CAS. +- `DatabaseSync` calls are synchronous and can block the Node event loop; + exclusive-writer protection remains process-local. diff --git a/packages/server/package.json b/packages/server/package.json index 5c0cf65..1f390ef 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@powercontext/server", "version": "0.0.0", - "description": "HTTP, MCP, Dashboard and observability composition.", + "description": "Experimental subset HTTP Server over the TypeScript runtime.", "license": "Apache-2.0", "type": "module", "sideEffects": false, @@ -26,7 +26,9 @@ }, "dependencies": { "@powercontext/builtin": "workspace:*", - "@powercontext/protocol": "workspace:*" + "@powercontext/core": "workspace:*", + "@powercontext/protocol": "workspace:*", + "fastify": "^5.2.0" }, "publishConfig": { "access": "public", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 983da9e..ed41833 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,7 +14,446 @@ * limitations under the License. */ +import { randomUUID } from 'node:crypto' +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest, +} from 'fastify' +import { + ArtifactNotFoundError, + SourceConflictError, + SourceNotFoundError, + UnavailableError, + ValidationError, +} from '@powercontext/core' +import { + OPERATION_METADATA, + validateOperationError, + validateOperationRequest, + validateOperationSuccess, + validateWireValue, +} from '@powercontext/protocol' +import { + openExperimentalRuntime, + type ExperimentalRuntime, + type MemoryEntry, +} from '@powercontext/builtin' + export const PACKAGE_NAME = '@powercontext/server' as const export const PACKAGE_VERSION = '0.0.0' as const export const PACKAGE_ROLE = 'server' as const export const PACKAGE_PROFILE = 'sqlite-fts' as const +export const EXPERIMENTAL_SUBSET = true + +const JSON_CONTENT_TYPE = 'application/json' +const REQUEST_ID_HEADER = 'X-PowerContext-Request-ID' + +export interface ExperimentalServerOptions { + readonly dbPath: string +} + +export interface ExperimentalListenOptions extends ExperimentalServerOptions { + readonly host?: string + readonly port: number +} + +export interface ExperimentalHttpServer { + readonly app: FastifyInstance + readonly runtime: ExperimentalRuntime | undefined + ready(): Promise + listen(): Promise + close(): Promise +} + +type JsonHandler = (request: FastifyRequest, reply: FastifyReply) => Promise + +type ErrorBody = { + readonly error: { + readonly code: string + readonly message: string + readonly details: Record | null + } +} + +function errorBody( + code: string, + message: string, + details: Record | null = null, +): ErrorBody { + return { error: { code, message, details } } +} + +function assertRequest(operationId: string, body: unknown): void { + const result = validateOperationRequest(operationId, body) + if (!result.valid) { + throw new ValidationError(validationMessages(result.errors) || 'invalid request') + } +} + +function validationMessages(errors: unknown): string { + if (!Array.isArray(errors)) { + return '' + } + return errors + .map((error: unknown) => { + if (typeof error === 'object' && error !== null && 'message' in error) { + return String(error.message) + } + return String(error) + }) + .join('; ') +} + +function sendSuccess( + operationId: string, + status: number, + value: unknown, + reply: FastifyReply, +): FastifyReply { + const result = validateOperationSuccess(operationId, status, JSON_CONTENT_TYPE, value) + if (!result.valid) { + throw new Error( + `${operationId} produced an invalid success response: ${validationMessages(result.errors)}`, + ) + } + return reply.code(status).type(JSON_CONTENT_TYPE).send(value) +} + +function sendError( + operationId: string, + status: number, + value: ErrorBody, + reply: FastifyReply, +): FastifyReply { + const result = validateOperationError(operationId, status, value) + if (!result.valid) { + throw new Error( + `${operationId} produced an invalid error response: ${validationMessages(result.errors)}`, + ) + } + return reply.code(status).type(JSON_CONTENT_TYPE).send(value) +} + +function sendReadinessFailure( + value: { readonly status: 'not_ready'; readonly checks: Record }, + reply: FastifyReply, +): FastifyReply { + const result = validateOperationError('get_readiness', 503, value) + if (!result.valid) { + throw new Error( + `get_readiness produced an invalid error response: ${validationMessages(result.errors)}`, + ) + } + return reply.code(503).type(JSON_CONTENT_TYPE).send(value) +} + +function sendUnknownError(reply: FastifyReply): FastifyReply { + const value = errorBody( + 'unavailable', + 'route is not registered by the experimental subset', + ) + const result = validateWireValue('ErrorResponse', value) + if (!result.valid) { + throw new Error('server produced an invalid not-found response') + } + return reply.code(404).type(JSON_CONTENT_TYPE).send(value) +} + +function artifactReference(entry: MemoryEntry): Record { + return { + family: entry.artifact.family, + artifact_id: entry.artifact.artifactId, + revision: entry.artifact.revision, + } +} + +function wireEntry(entry: MemoryEntry): Record { + const memoryRef = artifactReference(entry) + return { + citation: { + memory_ref: memoryRef, + entry_id: entry.entry_id, + entry_version_id: entry.entry_id, + }, + version: 1, + kind: entry.kind, + text: entry.text, + state: 'active', + source_refs: entry.source_refs.map((ref) => ({ + name: ref.source_type, + source_id: ref.source_id, + })), + artifact_refs: entry.artifact_refs, + } +} + +function registerJsonRoute( + app: FastifyInstance, + operationId: string, + handler: JsonHandler, +): void { + const metadata = OPERATION_METADATA[operationId as keyof typeof OPERATION_METADATA] + if (metadata === undefined) { + throw new Error(`unknown route operation: ${operationId}`) + } + const routeHandler = async ( + request: FastifyRequest, + reply: FastifyReply, + ): Promise => { + try { + assertRequest(operationId, request.body) + return await handler(request, reply) + } catch (error) { + return handleError(operationId, error, reply) + } + } + if (metadata.method === 'GET') { + app.get(metadata.path, routeHandler) + } else { + app.post(metadata.path, routeHandler) + } +} + +function handleError( + operationId: string, + error: unknown, + reply: FastifyReply, +): FastifyReply { + if (error instanceof ValidationError) { + if (/not found/i.test(error.message)) { + return sendError(operationId, 404, errorBody('not_found', error.message), reply) + } + return sendError( + operationId, + 422, + errorBody('invalid_request', error.message), + reply, + ) + } + if (error instanceof SourceConflictError) { + return sendError(operationId, 409, errorBody('conflict', error.message), reply) + } + if (error instanceof UnavailableError) { + return sendError(operationId, 503, errorBody('unavailable', error.message), reply) + } + if (error instanceof SourceNotFoundError || error instanceof ArtifactNotFoundError) { + return sendError(operationId, 404, errorBody('not_found', error.message), reply) + } + return sendError( + operationId, + 500, + errorBody( + 'internal_error', + error instanceof Error ? error.message : 'internal server error', + ), + reply, + ) +} + +function createApp(options: ExperimentalServerOptions): ExperimentalHttpServer { + const app = Fastify({ logger: false }) + let runtime: ExperimentalRuntime | undefined + let runtimeError: unknown + const runtimeReady = openExperimentalRuntime({ path: options.dbPath }) + .then((opened) => { + runtime = opened + }) + .catch((error: unknown) => { + runtimeError = error + }) + + app.addHook('onRequest', async (_request, reply) => { + reply.header(REQUEST_ID_HEADER, randomUUID()) + }) + + registerJsonRoute(app, 'get_liveness', async (_request, reply) => + sendSuccess('get_liveness', 200, { status: 'ok' }, reply), + ) + registerJsonRoute(app, 'get_readiness', async (_request, reply) => { + if (runtime === undefined) { + return sendReadinessFailure( + { + status: 'not_ready', + checks: { + database: runtimeError instanceof Error ? runtimeError.message : 'opening', + }, + }, + reply, + ) + } + return sendSuccess( + 'get_readiness', + 200, + { status: 'ready', checks: { database: 'open' } }, + reply, + ) + }) + registerJsonRoute(app, 'get_capabilities', async (_request, reply) => + sendSuccess( + 'get_capabilities', + 200, + { + source_types: ['content'], + artifact_families: ['memory'], + memory_extraction: false, + experience_generation: false, + managed_skill_generation: false, + external_skill_registry: false, + handoff_generation: false, + search_modes: ['fts'], + context_versions: [], + }, + reply, + ), + ) + registerJsonRoute(app, 'capture_content_source', async (_request, reply) => + sendError( + 'capture_content_source', + 503, + errorBody( + 'unavailable', + 'content Source capture is unavailable in the experimental skeleton', + ), + reply, + ), + ) + registerJsonRoute(app, 'remember_memory', async (request, reply) => { + if (runtime === undefined) { + return sendError( + 'remember_memory', + 503, + errorBody('unavailable', 'database is not ready'), + reply, + ) + } + const body = request.body as { + readonly scope_id: string + readonly kind: string + readonly text: string + } + const entry = await runtime.remember(body) + return sendSuccess( + 'remember_memory', + 200, + { memory: artifactReference(entry), entry: wireEntry(entry) }, + reply, + ) + }) + registerJsonRoute(app, 'search_memory', async (request, reply) => { + if (runtime === undefined) { + return sendError( + 'search_memory', + 503, + errorBody('unavailable', 'database is not ready'), + reply, + ) + } + const body = request.body as { + readonly scope_id: string + readonly query: string + readonly limit?: number + readonly mode?: 'auto' | 'fts' | 'vector' | 'hybrid' + } + const entries = await runtime.search({ + scope_id: body.scope_id, + query: body.query, + ...(body.limit === undefined ? {} : { limit: body.limit }), + mode: body.mode === 'auto' || body.mode === undefined ? 'fts' : body.mode, + }) + const hits = entries.map((entry) => ({ + citation: (wireEntry(entry) as { citation: unknown }).citation, + text: entry.text, + score: 1, + matched_by: ['fts'], + })) + return sendSuccess( + 'search_memory', + 200, + { + memory: entries[0] === undefined ? null : artifactReference(entries[0]), + mode: 'fts', + hits, + }, + reply, + ) + }) + registerJsonRoute(app, 'list_memory_entries', async (request, reply) => { + if (runtime === undefined) { + return sendError( + 'list_memory_entries', + 503, + errorBody('unavailable', 'database is not ready'), + reply, + ) + } + const body = request.body as { readonly scope_id: string } + const entries = await runtime.list(body.scope_id) + return sendSuccess( + 'list_memory_entries', + 200, + { + ...(entries[0] === undefined ? {} : { memory: artifactReference(entries[0]) }), + entries: entries.map(wireEntry), + }, + reply, + ) + }) + registerJsonRoute(app, 'get_memory_entry', async (request, reply) => { + if (runtime === undefined) { + return sendError( + 'get_memory_entry', + 503, + errorBody('unavailable', 'database is not ready'), + reply, + ) + } + const body = request.body as { + readonly scope_id: string + readonly citation: { readonly entry_id: string } + } + const entry = await runtime.get(body.scope_id, body.citation.entry_id) + return sendSuccess('get_memory_entry', 200, wireEntry(entry), reply) + }) + + app.setNotFoundHandler((_request, reply) => sendUnknownError(reply)) + + return { + app, + get runtime() { + return runtime + }, + async ready(): Promise { + await runtimeReady + }, + async listen(): Promise { + await runtimeReady + return app.listen({ host: '127.0.0.1', port: 0 }) + }, + async close(): Promise { + runtime?.close() + await app.close() + }, + } +} + +export function createServer( + options: ExperimentalServerOptions, +): ExperimentalHttpServer { + return createApp(options) +} + +export async function listen( + options: ExperimentalListenOptions, +): Promise { + if (options.host !== undefined && options.host !== '127.0.0.1') { + throw new ValidationError('experimental Server only listens on 127.0.0.1') + } + const server = createApp(options) + await server.ready() + await server.app.listen({ + host: options.host ?? '127.0.0.1', + port: options.port, + }) + return server +} diff --git a/packages/server/tests/package-info.test.ts b/packages/server/tests/package-info.test.ts index 384bb2c..0af0f5d 100644 --- a/packages/server/tests/package-info.test.ts +++ b/packages/server/tests/package-info.test.ts @@ -14,12 +14,28 @@ * limitations under the License. */ +import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { PACKAGE_NAME, PACKAGE_ROLE } from '../src/index.js' +import { + EXPERIMENTAL_SUBSET, + PACKAGE_NAME, + PACKAGE_PROFILE, + PACKAGE_ROLE, +} from '../src/index.js' describe('@powercontext/server skeleton', () => { it('exports the reserved server composition identity', () => { expect(PACKAGE_NAME).toBe('@powercontext/server') expect(PACKAGE_ROLE).toBe('server') + expect(PACKAGE_PROFILE).toBe('sqlite-fts') + expect(EXPERIMENTAL_SUBSET).toBe(true) + }) + + it('keeps the user status honest about the unshipped HTTP Server', () => { + const userReadme = readFileSync( + new URL('../../../docs/user/README.md', import.meta.url), + 'utf8', + ) + expect(userReadme).toContain('| HTTP Server, MCP, CLI, Dashboard | Not shipped |') }) }) diff --git a/packages/server/tests/subset-server.test.ts b/packages/server/tests/subset-server.test.ts new file mode 100644 index 0000000..341615f --- /dev/null +++ b/packages/server/tests/subset-server.test.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2026 OceanBase. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PowerContextClient } from '../../client/src/index.js' +import { listen, type ExperimentalHttpServer } from '../src/index.js' + +describe('experimental subset HTTP Server', () => { + const servers: ExperimentalHttpServer[] = [] + const directories: string[] = [] + + afterEach(async () => { + for (const server of servers.splice(0)) { + await server.close() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + async function start(): Promise<{ + server: ExperimentalHttpServer + client: PowerContextClient + }> { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-http-')) + directories.push(directory) + const server = await listen({ + host: '127.0.0.1', + port: 0, + dbPath: join(directory, 'runtime.sqlite3'), + }) + servers.push(server) + const address = server.app.server.address() + if (address === null || typeof address === 'string') { + throw new Error('test server did not expose a TCP address') + } + return { + server, + client: new PowerContextClient({ + baseUrl: `http://127.0.0.1:${String(address.port)}`, + }), + } + } + + it('serves liveness, readiness, capabilities, and request IDs', async () => { + const { server, client } = await start() + const live = await client.get_liveness() + expect(live.status).toBe('ok') + const ready = await client.get_readiness() + expect(ready.status).toBe('ready') + const capabilities = await client.get_capabilities() + expect(capabilities.search_modes).toEqual(['fts']) + expect(capabilities.memory_extraction).toBe(false) + expect(capabilities.handoff_generation).toBe(false) + expect(capabilities.context_versions).toEqual([]) + const response = await server.app.inject({ method: 'GET', url: '/health/live' }) + expect(response.headers['x-powercontext-request-id']).toMatch(/^[0-9a-f-]{36}$/) + }) + + it('keeps HTTP content capture unavailable instead of faking acceptance', async () => { + const { client } = await start() + await expect( + client.request('capture_content_source', { + scope_id: 'http-scope', + source_id: 'source-1', + content: 'captured content', + }), + ).rejects.toMatchObject({ + statusCode: 503, + code: 'unavailable', + }) + }) + + it('remembers and finds CJK text after a close and new listen', async () => { + const directory = mkdtempSync(join(tmpdir(), 'powercontext-http-restart-')) + directories.push(directory) + const dbPath = join(directory, 'runtime.sqlite3') + const first = await listen({ host: '127.0.0.1', port: 0, dbPath }) + servers.push(first) + const firstAddress = first.app.server.address() + if (firstAddress === null || typeof firstAddress === 'string') { + throw new Error('first test server did not expose a TCP address') + } + const firstClient = new PowerContextClient({ + baseUrl: `http://127.0.0.1:${String(firstAddress.port)}`, + }) + const remembered = await firstClient.remember_memory({ + scope_id: 'http-scope', + kind: 'note', + text: '中文 over HTTP', + }) + await first.close() + servers.splice(servers.indexOf(first), 1) + + const second = await listen({ host: '127.0.0.1', port: 0, dbPath }) + servers.push(second) + const secondAddress = second.app.server.address() + if (secondAddress === null || typeof secondAddress === 'string') { + throw new Error('second test server did not expose a TCP address') + } + const secondClient = new PowerContextClient({ + baseUrl: `http://127.0.0.1:${String(secondAddress.port)}`, + }) + const found = await secondClient.search_memory({ + scope_id: 'http-scope', + query: '中文', + }) + expect(found.hits.map((hit) => hit.text)).toContain('中文 over HTTP') + const listed = await secondClient.list_memory_entries({ scope_id: 'http-scope' }) + expect(listed.entries).toHaveLength(1) + if (remembered.entry === undefined) { + throw new Error('remember response did not include its entry') + } + const fetched = await secondClient.get_memory_entry({ + scope_id: 'http-scope', + citation: remembered.entry.citation, + }) + expect(fetched.text).toBe('中文 over HTTP') + expect(remembered.memory.family).toBe('memory') + }) + + it('reports vector and hybrid as unavailable and does not register work routes', async () => { + const { client } = await start() + await expect( + client.search_memory({ scope_id: 'http-scope', query: '中文', mode: 'vector' }), + ).rejects.toMatchObject({ + statusCode: 503, + code: 'unavailable', + }) + await expect( + client.search_memory({ scope_id: 'http-scope', query: '中文', mode: 'hybrid' }), + ).rejects.toMatchObject({ + statusCode: 503, + code: 'unavailable', + }) + await expect( + client.request('create_work_contract', { + scope_id: 'http-scope', + source_id: 'source-1', + contract: { + schema: 'powercontext.work-contract.v1', + trust: 'untrusted_input', + objective: 'test objective', + facts: [], + in_scope: ['test'], + exclusions: [], + completion_criteria: ['done'], + authorization_notes: [], + open_questions: [], + }, + }), + ).rejects.toMatchObject({ + statusCode: 404, + code: 'unavailable', + }) + }) + + it('maps runtime source refs to SourceReference names on the wire', async () => { + const { server, client } = await start() + const runtime = server.runtime + if (runtime === undefined) { + throw new Error('server runtime was not ready') + } + const entry = await runtime.remember({ + scope_id: 'http-scope', + kind: 'note', + text: 'source ref mapping', + source_refs: [{ sourceType: 'content', sourceId: 'source-1' }], + }) + const listed = await client.list_memory_entries({ scope_id: 'http-scope' }) + expect(listed.entries).toHaveLength(1) + expect(listed.entries[0]?.source_refs).toEqual([ + { name: 'content', source_id: 'source-1' }, + ]) + const fetched = await client.get_memory_entry({ + scope_id: 'http-scope', + citation: { + memory_ref: { + family: 'memory', + artifact_id: entry.artifact.artifactId, + revision: entry.artifact.revision, + }, + entry_id: entry.entry_id, + entry_version_id: entry.entry_id, + }, + }) + expect(fetched.source_refs).toEqual([{ name: 'content', source_id: 'source-1' }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b31287..075f3b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,9 +131,15 @@ importers: '@powercontext/builtin': specifier: workspace:* version: link:../builtin + '@powercontext/core': + specifier: workspace:* + version: link:../core '@powercontext/protocol': specifier: workspace:* version: link:../protocol + fastify: + specifier: ^5.2.0 + version: 5.12.1 packages: