@pepys/api 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@pepys/api</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@pepys/api"><img src="https://img.shields.io/npm/v/@pepys/api" alt="npm version"></a>
6
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
14
+ The pepys API, for Bun: typed queries over the store that the host writes.
15
+
16
+ A deployment serves the core queries and each plug-in's queries over oRPC and as MCP tools.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ bun add @pepys/api
22
+ ```
23
+
24
+ ## Features
25
+
26
+ - `serveAPI`, which serves a deployment's queries at `/rpc` and as MCP tools at `/mcp`.
27
+ - `DeploymentClient`, the type of a typed client of a deployment's queries.
28
+ - The store reader over the host's Turso files.
29
+
30
+ ## Documentation
31
+
32
+ The [pepys docs](../../docs/README.md) hold the design of the API and the store.
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@pepys/api",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The pepys API: typed queries over the store",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/pepys.git",
9
+ "directory": "packages/api"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/test-utils"
15
+ ],
16
+ "type": "module",
17
+ "exports": "./src/index.ts",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/server": "2.4.0",
23
+ "@orpc/contract": "2.0.0-beta.50",
24
+ "@orpc/mcp": "2.0.0-beta.50",
25
+ "@orpc/openapi": "2.0.0-beta.50",
26
+ "@orpc/server": "2.0.0-beta.50",
27
+ "@pepys/contract": "0.1.0",
28
+ "@pepys/kit": "0.1.0",
29
+ "@pepys/kysely-turso": "0.1.0",
30
+ "@pepys/schema": "0.1.0",
31
+ "@tursodatabase/database": "0.8.3",
32
+ "elysia": "2.0.0-rc.1",
33
+ "exact-mirror": "1.2.7",
34
+ "kysely": "0.29.8",
35
+ "openapi-types": "12.1.3",
36
+ "tiny-invariant": "1.3.3",
37
+ "ts-pattern": "5.9.2",
38
+ "type-fest": "5.10.0",
39
+ "typebox": "1.4.4",
40
+ "zod": "4.6.7"
41
+ },
42
+ "devDependencies": {
43
+ "@orpc/client": "2.0.0-beta.50",
44
+ "@pepys/testing": "0.1.0",
45
+ "@zgeoff/std": "0.4.3"
46
+ }
47
+ }
@@ -0,0 +1,37 @@
1
+ import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
2
+ import { McpRegistrar } from '@orpc/mcp';
3
+ import { COMMON_ERROR_STATUS_MAP } from '@orpc/server';
4
+ import { RPCHandler } from '@orpc/server/fetch';
5
+ import { Elysia } from 'elysia';
6
+ import type { QueryContext } from '../queries/types.ts';
7
+ import { createRouter } from './create-router.ts';
8
+
9
+ // The router at /rpc, and each of its procedures as an MCP tool at /mcp.
10
+ export function createApp(context: QueryContext) {
11
+ const router = createRouter(context.deployment.queries);
12
+
13
+ // A contract's own status has no effect in oRPC v2: the handler's map gives each code its HTTP
14
+ // status, and a code the map lacks gets 400. The map sets 503 itself, so it rests on no default.
15
+ const rpc = new RPCHandler(router, {
16
+ errorStatusMap: { ...COMMON_ERROR_STATUS_MAP, SERVICE_UNAVAILABLE: 503 },
17
+ });
18
+
19
+ const registrar = new McpRegistrar(router);
20
+
21
+ // stateless: one server per request, with the tools of the one router
22
+ const mcp = createMcpHandler(async () => {
23
+ const server = new McpServer({ name: `pepys-${context.deployment.name}`, version: '1.0.0' });
24
+
25
+ await registrar.register(server, { context });
26
+
27
+ return server;
28
+ });
29
+
30
+ return new Elysia()
31
+ .all('/rpc/*', { parse: 'none' }, async (route) => {
32
+ const result = await rpc.handle(route.request, { prefix: '/rpc', context });
33
+
34
+ return result.matched ? result.response : new Response('not found', { status: 404 });
35
+ })
36
+ .all('/mcp', { parse: 'none' }, (route) => mcp.fetch(route.request));
37
+ }
@@ -0,0 +1,85 @@
1
+ import { oc } from '@orpc/contract';
2
+ import { openapi } from '@orpc/openapi';
3
+ import { implement } from '@orpc/server';
4
+ import type { Router } from '@orpc/server';
5
+ import { coreContract } from '@pepys/contract';
6
+ import type { AnyQueryDefinition } from '@pepys/kit';
7
+ import { z } from 'zod';
8
+ import { readEntity } from '../queries/get-entity/read-entity.ts';
9
+ import { walkNeighbourhood } from '../queries/neighbourhood/walk-neighbourhood.ts';
10
+ import type { QueryContext } from '../queries/types.ts';
11
+ import { runPluginQuery } from './run-plugin-query.ts';
12
+
13
+ // One flat router: the two core procedures and each plug-in query, by its snake case name, which
14
+ // is also its MCP tool name. The deployment check refuses a query named as a core procedure.
15
+ export function createRouter(queries: readonly AnyQueryDefinition[]): Router<QueryContext> {
16
+ const core = implement(coreContract).$context<QueryContext>();
17
+
18
+ const router: Record<string, Router<QueryContext>> = {
19
+ get_entity: core.get_entity.handler(async (options) => {
20
+ const result = await readEntity(options.context, options.input.id);
21
+
22
+ if (!result.ok) {
23
+ const buildError = options.errors[result.failure.code];
24
+
25
+ throw buildError({ message: result.failure.message });
26
+ }
27
+
28
+ return result.value;
29
+ }),
30
+ neighbourhood: core.neighbourhood.handler(async (options) => {
31
+ const result = await walkNeighbourhood(options.context, options.input);
32
+
33
+ if (!result.ok) {
34
+ const buildError = options.errors[result.failure.code];
35
+
36
+ throw buildError({ message: result.failure.message });
37
+ }
38
+
39
+ return result.value;
40
+ }),
41
+ };
42
+
43
+ for (const query of queries) {
44
+ if (hasTransform(query)) {
45
+ throw new Error(
46
+ `the query ${query.name} has a zod transform, which an MCP tool schema cannot carry`,
47
+ );
48
+ }
49
+
50
+ const contract = oc
51
+ .errors({ SERVICE_UNAVAILABLE: {} })
52
+ .meta(openapi({ description: query.description }))
53
+ .input(query.input)
54
+ .output(query.output);
55
+
56
+ router[query.name] = implement(contract)
57
+ .$context<QueryContext>()
58
+ .handler(async (options) => {
59
+ const result = await runPluginQuery(options.context.store, query, options.input);
60
+
61
+ if (!result.ok) {
62
+ const buildUnavailable = options.errors.SERVICE_UNAVAILABLE;
63
+ const consumers = result.failure.consumers.join(', ');
64
+
65
+ throw buildUnavailable({ message: `the consumers ${consumers} have no current version` });
66
+ }
67
+
68
+ return result.value;
69
+ });
70
+ }
71
+
72
+ return router;
73
+ }
74
+
75
+ // zod writes no JSON Schema for the output of a transform, and throws instead.
76
+ function hasTransform(query: AnyQueryDefinition): boolean {
77
+ try {
78
+ z.toJSONSchema(query.input, { io: 'output' });
79
+ z.toJSONSchema(query.output, { io: 'output' });
80
+
81
+ return false;
82
+ } catch {
83
+ return true;
84
+ }
85
+ }
@@ -0,0 +1,31 @@
1
+ import type {
2
+ AnyQueryDefinition,
3
+ QueryDefinition,
4
+ Result,
5
+ StoreReader,
6
+ StoreReaderFailure,
7
+ } from '@pepys/kit';
8
+
9
+ // Runs one plug-in query's handler on the files of the consumers it reads. The router has parsed
10
+ // `input` with the query's own input schema, and parses the answer with its output schema.
11
+ export async function runPluginQuery(
12
+ store: StoreReader,
13
+ query: AnyQueryDefinition,
14
+ input: unknown,
15
+ ): Promise<Result<unknown, StoreReaderFailure>> {
16
+ const handler: unknown = query.handler;
17
+
18
+ if (!isQueryHandler(handler)) {
19
+ throw new TypeError(`the query ${query.name} has no handler`);
20
+ }
21
+
22
+ const result = await store.withConsumers(query.reads, (db) => handler({ input, db }));
23
+
24
+ return result;
25
+ }
26
+
27
+ // A list of queries types each handler's call as `never`, since no one call type fits every
28
+ // handler. Each handler takes the call that its own input schema parses, which the router gives.
29
+ function isQueryHandler(handler: unknown): handler is QueryDefinition['handler'] {
30
+ return typeof handler === 'function';
31
+ }
@@ -0,0 +1,9 @@
1
+ import type { ContractRouterClient } from '@orpc/contract';
2
+ import type { CoreContract } from '@pepys/contract';
3
+ import type { Deployment, PluginClient } from '@pepys/kit';
4
+ import type { UnionToIntersection } from 'type-fest';
5
+
6
+ // The typed client of one deployment: the core procedures, then each plug-in query by its name. A
7
+ // caller that needs one plug-in's queries alone uses the kit's PluginClient.
8
+ export type DeploymentClient<Target extends Deployment> = ContractRouterClient<CoreContract> &
9
+ UnionToIntersection<PluginClient<Target['plugins'][number]>>;
@@ -0,0 +1,33 @@
1
+ import type { Result } from '@pepys/kit';
2
+ import { parsePort } from '@pepys/schema';
3
+
4
+ export interface APIConfig {
5
+ storeDirectory: string;
6
+
7
+ // the listen address; 127.0.0.1 unless the deployment sets another
8
+ host: string;
9
+ port: number;
10
+ }
11
+
12
+ export function readAPIConfig(
13
+ environment: Readonly<Record<string, string | undefined>>,
14
+ ): Result<APIConfig, string[]> {
15
+ const storeDirectory = environment['PEPYS_STORE_DIR'] ?? '';
16
+ const host = environment['PEPYS_API_HOST'] ?? '';
17
+ const portText = environment['PEPYS_API_PORT'] ?? '';
18
+ const port = portText === '' ? ({ ok: true, value: 8081 } as const) : parsePort(portText);
19
+
20
+ const problems = [
21
+ ...(storeDirectory === '' ? ['PEPYS_STORE_DIR is not set'] : []),
22
+ ...(port.ok ? [] : ['PEPYS_API_PORT is not a port number']),
23
+ ];
24
+
25
+ if (!port.ok || problems.length > 0) {
26
+ return { ok: false, failure: problems };
27
+ }
28
+
29
+ return {
30
+ ok: true,
31
+ value: { storeDirectory, host: host === '' ? '127.0.0.1' : host, port: port.value },
32
+ };
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { createApp } from './app/create-app.ts';
2
+ export { createRouter } from './app/create-router.ts';
3
+ export type { DeploymentClient } from './client/deployment-client.ts';
4
+ export { readAPIConfig } from './config/read-api-config.ts';
5
+ export { buildQueryContext } from './queries/build-query-context.ts';
6
+ export type { QueryContext } from './queries/types.ts';
7
+ export { serveAPI } from './serve/serve-api.ts';
8
+ export { createStoreReader } from './store-reader/create-store-reader.ts';
9
+ export { readChangeOperations } from './store-reader/read-change-operations.ts';
10
+ export { readOffsets } from './store-reader/read-offsets.ts';
@@ -0,0 +1,13 @@
1
+ import { buildLinkRegistry } from '@pepys/kit';
2
+ import type { CheckedDeployment, StoreReader } from '@pepys/kit';
3
+ import { buildEntityKindIndex } from './entity-kinds/build-entity-kind-index.ts';
4
+ import type { QueryContext } from './types.ts';
5
+
6
+ export function buildQueryContext(store: StoreReader, deployment: CheckedDeployment): QueryContext {
7
+ return {
8
+ store,
9
+ deployment,
10
+ entityKinds: buildEntityKindIndex(deployment.consumers),
11
+ linkRegistry: buildLinkRegistry(deployment.linkTypes),
12
+ };
13
+ }
@@ -0,0 +1,17 @@
1
+ import type { ConsumerDeclaration, EntityKind, EntityViewDeclaration } from '@pepys/kit';
2
+
3
+ interface EntityOwner {
4
+ consumer: ConsumerDeclaration;
5
+ view: EntityViewDeclaration;
6
+ }
7
+
8
+ // Which consumer and table hold each kind. The deployment check refuses two views of one kind.
9
+ export type EntityKindIndex = ReadonlyMap<string, EntityOwner>;
10
+
11
+ export function buildEntityKindIndex(consumers: readonly ConsumerDeclaration[]): EntityKindIndex {
12
+ return new Map<EntityKind, EntityOwner>(
13
+ consumers.flatMap((consumer) =>
14
+ consumer.entityViews.map((view) => [view.kind, { consumer, view }] as const),
15
+ ),
16
+ );
17
+ }
@@ -0,0 +1,23 @@
1
+ import type { ColumnDeclaration, TableDeclaration } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+
4
+ export function buildEntityRow(
5
+ table: TableDeclaration,
6
+ stored: Readonly<Record<string, unknown>>,
7
+ ): Record<string, unknown> {
8
+ return Object.fromEntries(
9
+ Object.entries(table.columns).map(([name, column]) => [name, toValue(column, stored[name])]),
10
+ );
11
+ }
12
+
13
+ function toValue(column: ColumnDeclaration, stored: unknown): unknown {
14
+ return match(column.kind)
15
+ .with('boolean', () => (stored === null ? null : stored === 1))
16
+ .with('json', () => (typeof stored === 'string' ? parseJSON(stored) : stored))
17
+ .with('text', 'integer', 'real', 'entityID', 'timestamp', () => stored)
18
+ .exhaustive();
19
+ }
20
+
21
+ function parseJSON(text: string): unknown {
22
+ return JSON.parse(text);
23
+ }
@@ -0,0 +1,86 @@
1
+ import type { CoreOutputs } from '@pepys/contract';
2
+ import { parseEntityID } from '@pepys/kit';
3
+ import type { Result } from '@pepys/kit';
4
+ import { sql } from 'kysely';
5
+ import invariant from 'tiny-invariant';
6
+ import { z } from 'zod';
7
+ import type { QueryContext, QueryFailure } from '../types.ts';
8
+ import { buildEntityRow } from './build-entity-row.ts';
9
+
10
+ const storedRowSchema = z.record(z.string(), z.unknown());
11
+ const storedRowsSchema = z.object({ rows: z.array(storedRowSchema) });
12
+
13
+ type EntityOutput = CoreOutputs['get_entity'];
14
+
15
+ // The kind in the ID names the owning consumer, so the call attaches that one file.
16
+ export async function readEntity(
17
+ context: QueryContext,
18
+ id: string,
19
+ ): Promise<Result<EntityOutput, QueryFailure>> {
20
+ const parsed = parseEntityID(id);
21
+
22
+ if (!parsed.ok) {
23
+ return {
24
+ ok: false,
25
+ failure: {
26
+ code: 'BAD_REQUEST',
27
+ message: `${id} is not an entity ID: ${parsed.failure.reason}`,
28
+ },
29
+ };
30
+ }
31
+
32
+ const notFound = {
33
+ ok: false,
34
+ failure: { code: 'NOT_FOUND', message: `no entity ${id}` },
35
+ } as const;
36
+
37
+ const owner = context.entityKinds.get(`${parsed.value.source}:${parsed.value.kind}`);
38
+
39
+ if (owner === undefined) {
40
+ return notFound;
41
+ }
42
+
43
+ const read = await context.store.withConsumers([owner.consumer], async (db) => {
44
+ const entity = await db
45
+ .selectFrom('entity')
46
+ .selectAll()
47
+ .where('id', '=', id)
48
+ .executeTakeFirst();
49
+
50
+ if (entity === undefined) {
51
+ return null;
52
+ }
53
+
54
+ // the names come from the declaration, never from the input
55
+ const table = sql.table(owner.view.table);
56
+ const idColumn = sql.ref(owner.view.id);
57
+
58
+ const result = await sql`select * from ${table} where ${idColumn} = ${id}`.execute(db);
59
+
60
+ const [stored] = storedRowsSchema.parse(result).rows;
61
+ const declaration = owner.consumer.tables[owner.view.table];
62
+
63
+ invariant(stored !== undefined && declaration !== undefined, 'an entity row has a table row');
64
+
65
+ const value: EntityOutput = {
66
+ entity,
67
+ consumer: owner.consumer.name,
68
+ table: owner.view.table,
69
+ row: buildEntityRow(declaration, stored),
70
+ };
71
+
72
+ return value;
73
+ });
74
+
75
+ if (!read.ok) {
76
+ return {
77
+ ok: false,
78
+ failure: {
79
+ code: 'SERVICE_UNAVAILABLE',
80
+ message: `the consumer ${read.failure.consumers.join(', ')} has no current version`,
81
+ },
82
+ };
83
+ }
84
+
85
+ return read.value === null ? notFound : { ok: true, value: read.value };
86
+ }
@@ -0,0 +1,30 @@
1
+ import type { LinkRegistry, Result } from '@pepys/kit';
2
+
3
+ interface LinkTypeChoice {
4
+ linkTypes: readonly string[] | null;
5
+ includeHubs: boolean;
6
+ }
7
+
8
+ interface UnknownLinkTypes {
9
+ unknown: readonly string[];
10
+ known: readonly string[];
11
+ }
12
+
13
+ // The link types a walk follows: the named ones, or every one, with a hub type only when the
14
+ // caller includes hubs.
15
+ export function pickLinkTypes(
16
+ registry: LinkRegistry,
17
+ choice: LinkTypeChoice,
18
+ ): Result<readonly string[], UnknownLinkTypes> {
19
+ const known = [...registry.keys()].toSorted();
20
+ const named = choice.linkTypes ?? known;
21
+ const unknown = [...new Set(named.filter((name) => !registry.has(name)))].toSorted();
22
+
23
+ if (unknown.length > 0) {
24
+ return { ok: false, failure: { unknown, known } };
25
+ }
26
+
27
+ const followed = named.filter((name) => choice.includeHubs || registry.get(name)?.hub === false);
28
+
29
+ return { ok: true, value: [...new Set(followed)].toSorted() };
30
+ }
@@ -0,0 +1,97 @@
1
+ import type { LinkRow } from '@pepys/kit';
2
+ import { compareText } from '../../utils/compare-text.ts';
3
+
4
+ interface WalkLink {
5
+ type: string;
6
+ from: string;
7
+ to: string;
8
+ direction: 'outgoing' | 'incoming';
9
+ origin: string;
10
+ confidence: number;
11
+ depth: number;
12
+ }
13
+
14
+ export interface WalkState {
15
+ // every ID seen, with the depth it was first seen at; the cycle guard
16
+ depths: ReadonlyMap<string, number>;
17
+ links: readonly WalkLink[];
18
+
19
+ // the IDs the next hop expands
20
+ frontier: readonly string[];
21
+ isTruncated: boolean;
22
+ }
23
+
24
+ interface Hop {
25
+ depth: number;
26
+ entityLimit: number;
27
+ linkReadLimit: number;
28
+ }
29
+
30
+ // One hop, from the link rows that touch the frontier in read order. Two origins of one link merge
31
+ // at the highest confidence; new IDs join in ID order while the entity limit has room.
32
+ export function takeHop(state: WalkState, rows: readonly LinkRow[], hop: Hop): WalkState {
33
+ const frontier = new Set(state.frontier);
34
+ const followed = new Set(state.links.map((link) => toLinkKey(link.type, link.from, link.to)));
35
+ const merged = new Map<string, LinkRow>();
36
+
37
+ for (const row of rows.slice(0, hop.linkReadLimit)) {
38
+ const key = toLinkKey(row.type, row.from_id, row.to_id);
39
+ const held = merged.get(key);
40
+
41
+ if (!followed.has(key) && (held === undefined || row.confidence > held.confidence)) {
42
+ merged.set(key, row);
43
+ }
44
+ }
45
+
46
+ const candidates = [...merged.values()].map((row) => {
47
+ const isOutgoing = frontier.has(row.from_id);
48
+
49
+ return {
50
+ link: {
51
+ type: row.type,
52
+ from: row.from_id,
53
+ to: row.to_id,
54
+ direction: isOutgoing ? 'outgoing' : 'incoming',
55
+ origin: row.origin,
56
+ confidence: row.confidence,
57
+ depth: hop.depth,
58
+ } satisfies WalkLink,
59
+ reached: isOutgoing ? row.to_id : row.from_id,
60
+ };
61
+ });
62
+
63
+ const newIDs = [
64
+ ...new Set(
65
+ candidates.map((candidate) => candidate.reached).filter((id) => !state.depths.has(id)),
66
+ ),
67
+ ].toSorted();
68
+
69
+ const room = Math.max(0, hop.entityLimit - state.depths.size);
70
+ const admitted = newIDs.slice(0, room);
71
+
72
+ const depths = new Map([...state.depths, ...admitted.map((id) => [id, hop.depth] as const)]);
73
+
74
+ const links = candidates
75
+ .filter((candidate) => depths.has(candidate.reached))
76
+ .map((candidate) => candidate.link)
77
+ .toSorted(compareLinks);
78
+
79
+ return {
80
+ depths,
81
+ links: [...state.links, ...links],
82
+ frontier: admitted,
83
+ isTruncated: state.isTruncated || rows.length > hop.linkReadLimit || newIDs.length > room,
84
+ };
85
+ }
86
+
87
+ function toLinkKey(type: string, from: string, to: string): string {
88
+ return JSON.stringify([type, from, to]);
89
+ }
90
+
91
+ function compareLinks(first: WalkLink, second: WalkLink): number {
92
+ return (
93
+ compareText(first.type, second.type) ||
94
+ compareText(first.from, second.from) ||
95
+ compareText(first.to, second.to)
96
+ );
97
+ }
@@ -0,0 +1,121 @@
1
+ import type { CoreOutputs, CoreParsedInputs } from '@pepys/contract';
2
+ import { neighbourhoodCaps } from '@pepys/contract';
3
+ import { parseEntityID } from '@pepys/kit';
4
+ import type { Result } from '@pepys/kit';
5
+ import invariant from 'tiny-invariant';
6
+ import { compareText } from '../../utils/compare-text.ts';
7
+ import type { QueryContext, QueryFailure } from '../types.ts';
8
+ import { pickLinkTypes } from './pick-link-types.ts';
9
+ import { takeHop } from './take-hop.ts';
10
+ import type { WalkState } from './take-hop.ts';
11
+
12
+ type NeighbourhoodInput = CoreParsedInputs['neighbourhood'];
13
+
14
+ type NeighbourhoodOutput = CoreOutputs['neighbourhood'];
15
+
16
+ // A breadth-first walk with one read of the link view per hop. Every consumer with a current
17
+ // version is attached, and none is required, so a consumer with none is skipped and named.
18
+ export async function walkNeighbourhood(
19
+ context: QueryContext,
20
+ input: NeighbourhoodInput,
21
+ ): Promise<Result<NeighbourhoodOutput, QueryFailure<'NOT_FOUND' | 'BAD_REQUEST'>>> {
22
+ const parsed = parseEntityID(input.id);
23
+
24
+ if (!parsed.ok) {
25
+ const message = `${input.id} is not an entity ID: ${parsed.failure.reason}`;
26
+
27
+ return { ok: false, failure: { code: 'BAD_REQUEST', message } };
28
+ }
29
+
30
+ const kind = `${parsed.value.source}:${parsed.value.kind}`;
31
+
32
+ if (!context.deployment.entityKinds.some((known) => known === kind)) {
33
+ return {
34
+ ok: false,
35
+ failure: { code: 'NOT_FOUND', message: `no plug-in brings the kind ${kind}` },
36
+ };
37
+ }
38
+
39
+ const types = pickLinkTypes(context.linkRegistry, input);
40
+
41
+ if (!types.ok) {
42
+ const message = `unknown link types ${types.failure.unknown.join(', ')}; the known ones are ${types.failure.known.join(', ')}`;
43
+
44
+ return { ok: false, failure: { code: 'BAD_REQUEST', message } };
45
+ }
46
+
47
+ const read = await context.store.withConsumers(
48
+ [],
49
+ async (db, call) => {
50
+ let state: WalkState = {
51
+ depths: new Map([[input.id, 0]]),
52
+ links: [],
53
+ frontier: [input.id],
54
+ isTruncated: false,
55
+ };
56
+
57
+ for (let depth = 1; depth <= input.depthLimit && state.frontier.length > 0; depth += 1) {
58
+ const frontier = state.frontier;
59
+
60
+ const rows =
61
+ types.value.length === 0
62
+ ? []
63
+ : await db
64
+ .selectFrom('link_effective')
65
+ .selectAll()
66
+ .where('type', 'in', types.value)
67
+ .where('confidence', '>=', input.minimumConfidence)
68
+ .where((builder) =>
69
+ builder.or([
70
+ builder('from_id', 'in', frontier),
71
+ builder('to_id', 'in', frontier),
72
+ ]),
73
+ )
74
+ .orderBy('type')
75
+ .orderBy('from_id')
76
+ .orderBy('to_id')
77
+ .orderBy('origin')
78
+ .limit(neighbourhoodCaps.linkReadLimit + 1)
79
+ .execute();
80
+
81
+ state = takeHop(state, rows, {
82
+ depth,
83
+ entityLimit: input.entityLimit,
84
+ linkReadLimit: neighbourhoodCaps.linkReadLimit,
85
+ });
86
+ }
87
+
88
+ const entities = await db
89
+ .selectFrom('entity')
90
+ .selectAll()
91
+ .where('id', 'in', [...state.depths.keys()])
92
+ .execute();
93
+
94
+ return { state, entities, skipped: call.skipped };
95
+ },
96
+ { whenCurrent: context.deployment.consumers },
97
+ );
98
+
99
+ invariant(read.ok, 'a call with no required consumer always runs');
100
+
101
+ const state = read.value.state;
102
+
103
+ const found = new Set(read.value.entities.map((entity) => entity.id));
104
+
105
+ const entities = read.value.entities.map((entity) =>
106
+ Object.assign(entity, { depth: state.depths.get(entity.id) ?? 0 }),
107
+ );
108
+
109
+ return {
110
+ ok: true,
111
+ value: {
112
+ entities: entities.toSorted(
113
+ (first, second) => first.depth - second.depth || compareText(first.id, second.id),
114
+ ),
115
+ links: state.links.toSorted((first, second) => first.depth - second.depth),
116
+ unresolved: [...state.depths.keys()].filter((id) => !found.has(id)).toSorted(),
117
+ skippedConsumers: [...read.value.skipped],
118
+ isTruncated: state.isTruncated,
119
+ },
120
+ };
121
+ }
@@ -0,0 +1,19 @@
1
+ import type { CheckedDeployment, LinkRegistry, StoreReader } from '@pepys/kit';
2
+ import type { EntityKindIndex } from './entity-kinds/build-entity-kind-index.ts';
3
+
4
+ export interface QueryContext {
5
+ store: StoreReader;
6
+ deployment: CheckedDeployment;
7
+ entityKinds: EntityKindIndex;
8
+ linkRegistry: LinkRegistry;
9
+ }
10
+
11
+ export interface QueryFailure<
12
+ Code extends 'NOT_FOUND' | 'BAD_REQUEST' | 'SERVICE_UNAVAILABLE' =
13
+ | 'NOT_FOUND'
14
+ | 'BAD_REQUEST'
15
+ | 'SERVICE_UNAVAILABLE',
16
+ > {
17
+ code: Code;
18
+ message: string;
19
+ }
@@ -0,0 +1,53 @@
1
+ import { checkDeployment } from '@pepys/kit';
2
+ import type { Deployment } from '@pepys/kit';
3
+ import { makeShutdownHandler } from '@pepys/schema';
4
+ import { createApp } from '../app/create-app.ts';
5
+ import { readAPIConfig } from '../config/read-api-config.ts';
6
+ import { buildQueryContext } from '../queries/build-query-context.ts';
7
+ import { createStoreReader } from '../store-reader/create-store-reader.ts';
8
+
9
+ interface ServeAPIOptions {
10
+ deployment: Deployment;
11
+ environment: Readonly<Record<string, string | undefined>>;
12
+ }
13
+
14
+ // The API process of a deployment: oRPC at /rpc and MCP at /mcp, on the listen address from the
15
+ // environment. It exits 1 at start on a failed deployment check or a bad variable.
16
+ export function serveAPI(options: ServeAPIOptions): void {
17
+ const checked = checkDeployment(options.deployment);
18
+
19
+ if (!checked.ok) {
20
+ console.error('the deployment fails its check', JSON.stringify(checked.failure));
21
+ process.exit(1);
22
+ }
23
+
24
+ const config = readAPIConfig(options.environment);
25
+
26
+ if (!config.ok) {
27
+ console.error(config.failure.join('\n'));
28
+ process.exit(1);
29
+ }
30
+
31
+ const store = createStoreReader({ storeDirectory: config.value.storeDirectory });
32
+ const app = createApp(buildQueryContext(store, checked.value));
33
+
34
+ app.listen({ hostname: config.value.host, port: config.value.port });
35
+ console.log(`api listening on ${config.value.host}:${app.server?.port}`);
36
+
37
+ const shutdown = makeShutdownHandler({
38
+ stop: async () => {
39
+ await app.stop();
40
+ await store.close();
41
+ },
42
+ logError: (message, error) => {
43
+ console.error(message, error);
44
+ },
45
+ exit: (code) => process.exit(code),
46
+ });
47
+
48
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
49
+ process.on(signal, () => {
50
+ void shutdown();
51
+ });
52
+ }
53
+ }
@@ -0,0 +1,86 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import {
3
+ DummyDriver,
4
+ Kysely,
5
+ SelectQueryNode,
6
+ SqliteAdapter,
7
+ SqliteIntrospector,
8
+ SqliteQueryCompiler,
9
+ sql,
10
+ } from 'kysely';
11
+ import type { KyselyPlugin, WithNode } from 'kysely';
12
+ import invariant from 'tiny-invariant';
13
+
14
+ // Turso refuses a TEMP view over an attached file, so the shared views are common table expressions
15
+ // in front of every query. Each file holds its own `entity` view, and a bare name reads the first
16
+ // attached file's alone; a common table expression of the same name wins over it.
17
+ export function buildSharedViewsPlugin(attached: readonly ConsumerDeclaration[]): KyselyPlugin {
18
+ const views = collectSharedViews(attached);
19
+
20
+ return {
21
+ transformQuery: (args) => {
22
+ if (!SelectQueryNode.is(args.node)) {
23
+ return args.node;
24
+ }
25
+
26
+ return {
27
+ ...args.node,
28
+ with: {
29
+ ...views,
30
+ expressions: [...views.expressions, ...(args.node.with?.expressions ?? [])],
31
+ },
32
+ };
33
+ },
34
+ transformResult: (args) => Promise.resolve(args.result),
35
+ };
36
+ }
37
+
38
+ // A builder that compiles and never connects: it only makes the operation nodes. Its tables are
39
+ // the attached files' own, named `<consumer>.entity` and `<consumer>.link`.
40
+ const builder = new Kysely<Record<string, Record<string, unknown>>>({
41
+ dialect: {
42
+ createAdapter: () => new SqliteAdapter(),
43
+ createDriver: () => new DummyDriver(),
44
+ createIntrospector: (db) => new SqliteIntrospector(db),
45
+ createQueryCompiler: () => new SqliteQueryCompiler(),
46
+ },
47
+ });
48
+
49
+ const entityColumns = ['id', 'kind', 'title', 'url', 'actor', 'updated_at'];
50
+ const linkColumns = ['type', 'from_id', 'to_id', 'scope', 'origin', 'confidence', 'evidence'];
51
+
52
+ function collectSharedViews(attached: readonly ConsumerDeclaration[]): WithNode {
53
+ const entityFiles = attached.filter((consumer) => consumer.entityViews.length > 0);
54
+ const linkFiles = attached.filter((consumer) => consumer.links.length > 0);
55
+
56
+ const withNode = builder
57
+ .with('entity', (creator) => {
58
+ const [first, ...rest] = entityFiles.map((consumer) =>
59
+ creator.selectFrom(`${consumer.name}.entity`).select(entityColumns),
60
+ );
61
+
62
+ // no attached file has an entity view: the same columns, and no rows
63
+ if (first === undefined) {
64
+ return creator.selectNoFrom(entityColumns.map((column) => sql`null`.as(column))).limit(0);
65
+ }
66
+
67
+ return rest.reduce((union, next) => union.unionAll(next), first);
68
+ })
69
+ .with('link_effective', (creator) => {
70
+ const [first, ...rest] = linkFiles.map((consumer) =>
71
+ creator.selectFrom(`${consumer.name}.link`).select(linkColumns),
72
+ );
73
+
74
+ if (first === undefined) {
75
+ return creator.selectNoFrom(linkColumns.map((column) => sql`null`.as(column))).limit(0);
76
+ }
77
+
78
+ return rest.reduce((union, next) => union.unionAll(next), first);
79
+ })
80
+ .selectNoFrom(sql`1`.as('one'))
81
+ .toOperationNode().with;
82
+
83
+ invariant(withNode !== undefined, 'a query with two common table expressions has a with node');
84
+
85
+ return withNode;
86
+ }
@@ -0,0 +1,187 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import type { ConsumerDeclaration, ConsumersDB, StoreReader } from '@pepys/kit';
4
+ import { createTursoDialect } from '@pepys/kysely-turso';
5
+ import { connect } from '@tursodatabase/database';
6
+ import type { Database } from '@tursodatabase/database';
7
+ import { Kysely } from 'kysely';
8
+ import { buildSharedViewsPlugin } from './build-shared-views-plugin.ts';
9
+ import { pickAttachedFiles } from './pick-attached-files.ts';
10
+ import type { AttachedFile, CurrentFile } from './pick-attached-files.ts';
11
+
12
+ interface StoreReaderOptions {
13
+ storeDirectory: string;
14
+ }
15
+
16
+ // One open read-only handle on a consumer file. Turso 0.8.3 refuses to attach a file that the
17
+ // host holds open with its multi-process WAL, unless this process holds a handle of its own.
18
+ interface FileKeeper {
19
+ path: string;
20
+ database: Promise<Database>;
21
+ readers: number;
22
+ isRetired: boolean;
23
+ }
24
+
25
+ // The Turso adapter of the store reader port. Each call opens one connection on an empty
26
+ // in-memory database, attaches the current file of each consumer read-only under the consumer's
27
+ // name, and runs the work in one transaction, which holds one snapshot of each file.
28
+ export function createStoreReader(options: StoreReaderOptions): StoreReader {
29
+ const keepers = new Map<string, FileKeeper>();
30
+ const retired = new Set<FileKeeper>();
31
+
32
+ const holdKeeper = (file: AttachedFile): FileKeeper => {
33
+ const held = keepers.get(file.consumer.name);
34
+
35
+ if (held?.path === file.path) {
36
+ held.readers += 1;
37
+
38
+ return held;
39
+ }
40
+
41
+ const keeper: FileKeeper = {
42
+ path: file.path,
43
+ database: connect(file.path, { readonly: true, experimental: ['multiprocess_wal'] }),
44
+ readers: 1,
45
+ isRetired: false,
46
+ };
47
+
48
+ keepers.set(file.consumer.name, keeper);
49
+
50
+ // the host swapped the version: the old file closes once its last call ends
51
+ if (held !== undefined) {
52
+ held.isRetired = true;
53
+
54
+ retired.add(held);
55
+ }
56
+
57
+ return keeper;
58
+ };
59
+
60
+ const releaseKeeper = async (keeper: FileKeeper): Promise<void> => {
61
+ keeper.readers -= 1;
62
+
63
+ if (keeper.isRetired && keeper.readers === 0 && retired.delete(keeper)) {
64
+ const database = await keeper.database;
65
+
66
+ await database.close();
67
+ }
68
+ };
69
+
70
+ return {
71
+ withConsumers: async (consumers, work, callOptions) => {
72
+ // every file resolves before anything opens, so a swap during the call changes nothing
73
+ const [required, optional] = await Promise.all([
74
+ readCurrentFiles(options.storeDirectory, consumers),
75
+ readCurrentFiles(options.storeDirectory, callOptions?.whenCurrent ?? []),
76
+ ]);
77
+
78
+ const plan = pickAttachedFiles(required, optional);
79
+
80
+ if (plan.kind === 'missing') {
81
+ return { ok: false, failure: { reason: 'no-current-version', consumers: plan.consumers } };
82
+ }
83
+
84
+ const held = plan.files.map((file) => holdKeeper(file));
85
+
86
+ try {
87
+ await Promise.all(held.map((keeper) => keeper.database));
88
+
89
+ const connection = await connect(':memory:', { experimental: ['attach'] });
90
+
91
+ const db = new Kysely<ConsumersDB<typeof consumers>>({
92
+ dialect: createTursoDialect({ database: () => Promise.resolve(connection) }),
93
+ });
94
+
95
+ try {
96
+ for (const file of plan.files) {
97
+ await connection.exec(formatAttachStatement(file));
98
+ }
99
+
100
+ const call = {
101
+ attached: Object.fromEntries(
102
+ plan.files.map((file): [string, { version: string }] => [
103
+ file.consumer.name,
104
+ { version: basename(dirname(file.path)) },
105
+ ]),
106
+ ),
107
+ skipped: plan.skipped,
108
+ };
109
+
110
+ const views = buildSharedViewsPlugin(plan.files.map((file) => file.consumer));
111
+
112
+ // A failed write ends Turso's transaction itself, and Kysely's own rollback would then
113
+ // fail and hide the first error, so the call opens and ends the transaction by hand.
114
+ await connection.exec('begin');
115
+
116
+ try {
117
+ const value = await work(db.withPlugin(views), call);
118
+
119
+ return { ok: true, value };
120
+ } finally {
121
+ if (connection.inTransaction) {
122
+ await connection.exec('rollback');
123
+ }
124
+ }
125
+ } finally {
126
+ await db.destroy();
127
+ }
128
+ } finally {
129
+ await Promise.all(held.map((keeper) => releaseKeeper(keeper)));
130
+ }
131
+ },
132
+ close: async () => {
133
+ const open = [...keepers.values(), ...retired];
134
+
135
+ keepers.clear();
136
+ retired.clear();
137
+
138
+ await Promise.all(
139
+ open.map(async (keeper) => {
140
+ const database = await keeper.database;
141
+
142
+ await database.close();
143
+ }),
144
+ );
145
+ },
146
+ };
147
+ }
148
+
149
+ function readCurrentFiles(
150
+ storeDirectory: string,
151
+ consumers: readonly ConsumerDeclaration[],
152
+ ): Promise<CurrentFile[]> {
153
+ return Promise.all(
154
+ consumers.map(async (consumer) => {
155
+ const path = await readRealPath(
156
+ join(storeDirectory, consumer.name, 'current', 'consumer.db'),
157
+ );
158
+
159
+ return { consumer, path };
160
+ }),
161
+ );
162
+ }
163
+
164
+ async function readRealPath(path: string): Promise<string | null> {
165
+ try {
166
+ const resolved = await realpath(path);
167
+
168
+ return resolved;
169
+ } catch (error: unknown) {
170
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
171
+ return null;
172
+ }
173
+
174
+ throw error;
175
+ }
176
+ }
177
+
178
+ // A URI opens the file read-only. The path is percent-encoded, so a `?`, `#`, `%` or quote in a
179
+ // folder name stays part of the path.
180
+ function formatAttachStatement(file: AttachedFile): string {
181
+ const encoded = encodeURI(file.path)
182
+ .replaceAll('?', '%3F')
183
+ .replaceAll('#', '%23')
184
+ .replaceAll("'", '%27');
185
+
186
+ return `ATTACH 'file:${encoded}?mode=ro' AS "${file.consumer.name.replaceAll('"', '""')}"`;
187
+ }
@@ -0,0 +1,45 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+
3
+ export interface CurrentFile {
4
+ consumer: ConsumerDeclaration;
5
+
6
+ // the consumer file of the current version, every link resolved; null with no current version
7
+ path: string | null;
8
+ }
9
+
10
+ export interface AttachedFile {
11
+ consumer: ConsumerDeclaration;
12
+ path: string;
13
+ }
14
+
15
+ export type AttachPlan =
16
+ | { kind: 'missing'; consumers: readonly string[] }
17
+ | { kind: 'attach'; files: readonly AttachedFile[]; skipped: readonly string[] };
18
+
19
+ // Which files one call attaches: every required consumer's, else the call fails naming each one
20
+ // with no current version; then each optional consumer's that has one. A name attaches once.
21
+ export function pickAttachedFiles(
22
+ required: readonly CurrentFile[],
23
+ optional: readonly CurrentFile[],
24
+ ): AttachPlan {
25
+ const missing = required.filter((file) => file.path === null).map((file) => file.consumer.name);
26
+
27
+ if (missing.length > 0) {
28
+ return { kind: 'missing', consumers: missing };
29
+ }
30
+
31
+ const files = new Map<string, AttachedFile>();
32
+ const skipped = new Set<string>();
33
+
34
+ for (const file of [...required, ...optional]) {
35
+ const name = file.consumer.name;
36
+
37
+ if (file.path === null) {
38
+ skipped.add(name);
39
+ } else if (!files.has(name)) {
40
+ files.set(name, { consumer: file.consumer, path: file.path });
41
+ }
42
+ }
43
+
44
+ return { kind: 'attach', files: [...files.values()], skipped: [...skipped] };
45
+ }
@@ -0,0 +1,44 @@
1
+ import type { ConsumerDeclaration, Result, StoreReader, StoreReaderFailure } from '@pepys/kit';
2
+ import { sql } from 'kysely';
3
+ import { z } from 'zod';
4
+
5
+ interface ChangeOperation {
6
+ key: string;
7
+ operation: string;
8
+ }
9
+
10
+ const changeLogRowSchema = z.object({ changes: z.string() });
11
+ const changeLogRowsSchema = z.object({ rows: z.array(changeLogRowSchema) });
12
+
13
+ const changeSchema = z.object({
14
+ key: z.string(),
15
+ headers: z.object({ operation: z.string() }),
16
+ });
17
+
18
+ // each change log row holds one batch as a JSON list of State Protocol messages
19
+ const changesSchema = z.array(changeSchema);
20
+
21
+ export async function readChangeOperations(
22
+ store: StoreReader,
23
+ consumer: ConsumerDeclaration,
24
+ ): Promise<Result<ChangeOperation[], StoreReaderFailure>> {
25
+ const table = sql.table(`${consumer.name}.change_log`);
26
+
27
+ const result = await store.withConsumers([consumer], (db) =>
28
+ sql`select changes from ${table} order by batch`.execute(db),
29
+ );
30
+
31
+ if (!result.ok) {
32
+ return result;
33
+ }
34
+
35
+ const operations = changeLogRowsSchema.parse(result.value).rows.flatMap((row) => {
36
+ const changes: unknown = JSON.parse(row.changes);
37
+
38
+ return changesSchema
39
+ .parse(changes)
40
+ .map((change) => ({ key: change.key, operation: change.headers.operation }));
41
+ });
42
+
43
+ return { ok: true, value: operations };
44
+ }
@@ -0,0 +1,33 @@
1
+ import type { ConsumerDeclaration, Result, StoreReader, StoreReaderFailure } from '@pepys/kit';
2
+ import { sql } from 'kysely';
3
+ import { z } from 'zod';
4
+
5
+ interface Offset {
6
+ stream: string;
7
+ lastSeqNum: number;
8
+ }
9
+
10
+ const offsetRowSchema = z.object({ stream: z.string(), last_seq_num: z.number() });
11
+ const offsetRowsSchema = z.object({ rows: z.array(offsetRowSchema) });
12
+
13
+ // The last sequence number the host applied from each stream the consumer reads.
14
+ export async function readOffsets(
15
+ store: StoreReader,
16
+ consumer: ConsumerDeclaration,
17
+ ): Promise<Result<Offset[], StoreReaderFailure>> {
18
+ const table = sql.table(`${consumer.name}.offsets`);
19
+
20
+ const result = await store.withConsumers([consumer], (db) =>
21
+ sql`select stream, last_seq_num from ${table} order by stream`.execute(db),
22
+ );
23
+
24
+ if (!result.ok) {
25
+ return result;
26
+ }
27
+
28
+ const offsets = offsetRowsSchema
29
+ .parse(result.value)
30
+ .rows.map((row) => ({ stream: row.stream, lastSeqNum: row.last_seq_num }));
31
+
32
+ return { ok: true, value: offsets };
33
+ }
@@ -0,0 +1,8 @@
1
+ // Code unit order, as the default sort and SQLite's binary collation give, not the locale's.
2
+ export function compareText(first: string, second: string): number {
3
+ if (first === second) {
4
+ return 0;
5
+ }
6
+
7
+ return first < second ? -1 : 1;
8
+ }