@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 +31 -2
- package/package.json +45 -4
- package/src/app/create-app.ts +37 -0
- package/src/app/create-router.ts +85 -0
- package/src/app/run-plugin-query.ts +31 -0
- package/src/client/deployment-client.ts +9 -0
- package/src/config/read-api-config.ts +33 -0
- package/src/index.ts +10 -0
- package/src/queries/build-query-context.ts +13 -0
- package/src/queries/entity-kinds/build-entity-kind-index.ts +17 -0
- package/src/queries/get-entity/build-entity-row.ts +23 -0
- package/src/queries/get-entity/read-entity.ts +86 -0
- package/src/queries/neighbourhood/pick-link-types.ts +30 -0
- package/src/queries/neighbourhood/take-hop.ts +97 -0
- package/src/queries/neighbourhood/walk-neighbourhood.ts +121 -0
- package/src/queries/types.ts +19 -0
- package/src/serve/serve-api.ts +53 -0
- package/src/store-reader/build-shared-views-plugin.ts +86 -0
- package/src/store-reader/create-store-reader.ts +187 -0
- package/src/store-reader/pick-attached-files.ts +45 -0
- package/src/store-reader/read-change-operations.ts +44 -0
- package/src/store-reader/read-offsets.ts +33 -0
- package/src/utils/compare-text.ts +8 -0
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@pepys/api</h1>
|
|
2
3
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|