@pepys/api 0.0.1 → 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 +3 -2
- package/package.json +13 -9
- package/src/app/create-app.ts +27 -4
- package/src/app/create-router.ts +65 -48
- package/src/app/run-plugin-query.ts +31 -0
- package/src/client/deployment-client.ts +9 -0
- package/src/config/read-api-config.ts +8 -1
- package/src/index.ts +5 -6
- 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/read-entity.ts +43 -27
- 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 +12 -5
- 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 +146 -82
- package/src/store-reader/pick-attached-files.ts +45 -0
- package/src/store-reader/read-change-operations.ts +18 -9
- package/src/store-reader/read-offsets.ts +16 -11
- package/src/utils/compare-text.ts +8 -0
- package/src/change-feed/change-feed.ts +0 -33
- package/src/principal/principal.ts +0 -10
- package/src/queries/get-entity/find-entity-consumer.ts +0 -23
- package/src/store-reader/open-consumer-database.ts +0 -6
- package/src/store-reader/read-pull-requests.ts +0 -13
- package/src/store-reader/store-reader.ts +0 -37
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
The pepys API, for Bun: typed queries over the store that the host writes.
|
|
15
15
|
|
|
16
|
-
A deployment serves the core queries and each plug-in's queries over oRPC.
|
|
16
|
+
A deployment serves the core queries and each plug-in's queries over oRPC and as MCP tools.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -23,7 +23,8 @@ bun add @pepys/api
|
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
|
-
-
|
|
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.
|
|
27
28
|
- The store reader over the host's Turso files.
|
|
28
29
|
|
|
29
30
|
## Documentation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/api",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "The pepys API: typed queries over the store",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@
|
|
23
|
-
"@orpc/
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
-
"@
|
|
27
|
-
"@pepys/
|
|
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",
|
|
28
31
|
"@tursodatabase/database": "0.8.3",
|
|
29
32
|
"elysia": "2.0.0-rc.1",
|
|
30
33
|
"exact-mirror": "1.2.7",
|
|
@@ -32,12 +35,13 @@
|
|
|
32
35
|
"openapi-types": "12.1.3",
|
|
33
36
|
"tiny-invariant": "1.3.3",
|
|
34
37
|
"ts-pattern": "5.9.2",
|
|
38
|
+
"type-fest": "5.10.0",
|
|
35
39
|
"typebox": "1.4.4",
|
|
36
40
|
"zod": "4.6.7"
|
|
37
41
|
},
|
|
38
42
|
"devDependencies": {
|
|
39
|
-
"@orpc/client": "
|
|
40
|
-
"@pepys/
|
|
43
|
+
"@orpc/client": "2.0.0-beta.50",
|
|
44
|
+
"@pepys/testing": "0.1.0",
|
|
41
45
|
"@zgeoff/std": "0.4.3"
|
|
42
46
|
}
|
|
43
47
|
}
|
package/src/app/create-app.ts
CHANGED
|
@@ -1,14 +1,37 @@
|
|
|
1
|
+
import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
|
|
2
|
+
import { McpRegistrar } from '@orpc/mcp';
|
|
3
|
+
import { COMMON_ERROR_STATUS_MAP } from '@orpc/server';
|
|
1
4
|
import { RPCHandler } from '@orpc/server/fetch';
|
|
2
5
|
import { Elysia } from 'elysia';
|
|
3
6
|
import type { QueryContext } from '../queries/types.ts';
|
|
4
7
|
import { createRouter } from './create-router.ts';
|
|
5
8
|
|
|
9
|
+
// The router at /rpc, and each of its procedures as an MCP tool at /mcp.
|
|
6
10
|
export function createApp(context: QueryContext) {
|
|
7
|
-
const
|
|
11
|
+
const router = createRouter(context.deployment.queries);
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
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' });
|
|
11
24
|
|
|
12
|
-
|
|
25
|
+
await registrar.register(server, { context });
|
|
26
|
+
|
|
27
|
+
return server;
|
|
13
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));
|
|
14
37
|
}
|
package/src/app/create-router.ts
CHANGED
|
@@ -1,68 +1,85 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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';
|
|
3
5
|
import { coreContract } from '@pepys/contract';
|
|
4
|
-
import type {
|
|
6
|
+
import type { AnyQueryDefinition } from '@pepys/kit';
|
|
7
|
+
import { z } from 'zod';
|
|
5
8
|
import { readEntity } from '../queries/get-entity/read-entity.ts';
|
|
9
|
+
import { walkNeighbourhood } from '../queries/neighbourhood/walk-neighbourhood.ts';
|
|
6
10
|
import type { QueryContext } from '../queries/types.ts';
|
|
11
|
+
import { runPluginQuery } from './run-plugin-query.ts';
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
initialInputValidationIndex: Number.POSITIVE_INFINITY,
|
|
13
|
-
}).$context<QueryContext>();
|
|
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>();
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
get_entity:
|
|
18
|
+
const router: Record<string, Router<QueryContext>> = {
|
|
19
|
+
get_entity: core.get_entity.handler(async (options) => {
|
|
17
20
|
const result = await readEntity(options.context, options.input.id);
|
|
18
21
|
|
|
19
|
-
|
|
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);
|
|
20
32
|
|
|
21
33
|
if (!result.ok) {
|
|
22
|
-
|
|
34
|
+
const buildError = options.errors[result.failure.code];
|
|
35
|
+
|
|
36
|
+
throw buildError({ message: result.failure.message });
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
return result.value;
|
|
26
40
|
}),
|
|
27
|
-
|
|
28
|
-
search: api.search.handler(throwNotImplemented),
|
|
29
|
-
entity_history: api.entity_history.handler(throwNotImplemented),
|
|
30
|
-
assert_link: api.assert_link
|
|
31
|
-
.use(async (options) => {
|
|
32
|
-
const buildUnprocessable = options.errors.UNPROCESSABLE_CONTENT;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
return await options.next();
|
|
36
|
-
} catch (error) {
|
|
37
|
-
if (isMissingEvidence(error)) {
|
|
38
|
-
throw buildUnprocessable({ cause: error });
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
throw error;
|
|
42
|
-
}
|
|
43
|
-
})
|
|
44
|
-
.handler(throwNotImplemented),
|
|
45
|
-
retract_link: api.retract_link.handler(throwNotImplemented),
|
|
46
|
-
correct_label: api.correct_label.handler(throwNotImplemented),
|
|
47
|
-
});
|
|
48
|
-
}
|
|
41
|
+
};
|
|
49
42
|
|
|
50
|
-
|
|
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
|
+
}
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
50
|
+
const contract = oc
|
|
51
|
+
.errors({ SERVICE_UNAVAILABLE: {} })
|
|
52
|
+
.meta(openapi({ description: query.description }))
|
|
53
|
+
.input(query.input)
|
|
54
|
+
.output(query.output);
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
router[query.name] = implement(contract)
|
|
57
|
+
.$context<QueryContext>()
|
|
58
|
+
.handler(async (options) => {
|
|
59
|
+
const result = await runPluginQuery(options.context.store, query, options.input);
|
|
58
60
|
|
|
59
|
-
|
|
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;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
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]>>;
|
|
@@ -3,6 +3,9 @@ import { parsePort } from '@pepys/schema';
|
|
|
3
3
|
|
|
4
4
|
export interface APIConfig {
|
|
5
5
|
storeDirectory: string;
|
|
6
|
+
|
|
7
|
+
// the listen address; 127.0.0.1 unless the deployment sets another
|
|
8
|
+
host: string;
|
|
6
9
|
port: number;
|
|
7
10
|
}
|
|
8
11
|
|
|
@@ -10,6 +13,7 @@ export function readAPIConfig(
|
|
|
10
13
|
environment: Readonly<Record<string, string | undefined>>,
|
|
11
14
|
): Result<APIConfig, string[]> {
|
|
12
15
|
const storeDirectory = environment['PEPYS_STORE_DIR'] ?? '';
|
|
16
|
+
const host = environment['PEPYS_API_HOST'] ?? '';
|
|
13
17
|
const portText = environment['PEPYS_API_PORT'] ?? '';
|
|
14
18
|
const port = portText === '' ? ({ ok: true, value: 8081 } as const) : parsePort(portText);
|
|
15
19
|
|
|
@@ -22,5 +26,8 @@ export function readAPIConfig(
|
|
|
22
26
|
return { ok: false, failure: problems };
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
value: { storeDirectory, host: host === '' ? '127.0.0.1' : host, port: port.value },
|
|
32
|
+
};
|
|
26
33
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
export { createRouter } from './app/create-router.ts';
|
|
2
|
-
export type { APIClient } from './app/create-router.ts';
|
|
3
1
|
export { createApp } from './app/create-app.ts';
|
|
4
|
-
export
|
|
5
|
-
export type
|
|
2
|
+
export { createRouter } from './app/create-router.ts';
|
|
3
|
+
export type { DeploymentClient } from './client/deployment-client.ts';
|
|
6
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';
|
|
7
8
|
export { createStoreReader } from './store-reader/create-store-reader.ts';
|
|
8
|
-
export { openConsumerDatabase } from './store-reader/open-consumer-database.ts';
|
|
9
9
|
export { readChangeOperations } from './store-reader/read-change-operations.ts';
|
|
10
10
|
export { readOffsets } from './store-reader/read-offsets.ts';
|
|
11
|
-
export { readPullRequests } from './store-reader/read-pull-requests.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
|
+
}
|
|
@@ -1,70 +1,86 @@
|
|
|
1
1
|
import type { CoreOutputs } from '@pepys/contract';
|
|
2
|
-
import
|
|
2
|
+
import { parseEntityID } from '@pepys/kit';
|
|
3
|
+
import type { Result } from '@pepys/kit';
|
|
3
4
|
import { sql } from 'kysely';
|
|
4
5
|
import invariant from 'tiny-invariant';
|
|
5
6
|
import { z } from 'zod';
|
|
6
7
|
import type { QueryContext, QueryFailure } from '../types.ts';
|
|
7
8
|
import { buildEntityRow } from './build-entity-row.ts';
|
|
8
|
-
import { findEntityConsumer } from './find-entity-consumer.ts';
|
|
9
9
|
|
|
10
10
|
const storedRowSchema = z.record(z.string(), z.unknown());
|
|
11
11
|
const storedRowsSchema = z.object({ rows: z.array(storedRowSchema) });
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
id: EntityID;
|
|
15
|
-
parts: EntityIDParts;
|
|
16
|
-
}
|
|
13
|
+
type EntityOutput = CoreOutputs['get_entity'];
|
|
17
14
|
|
|
15
|
+
// The kind in the ID names the owning consumer, so the call attaches that one file.
|
|
18
16
|
export async function readEntity(
|
|
19
17
|
context: QueryContext,
|
|
20
|
-
|
|
21
|
-
): Promise<Result<
|
|
22
|
-
const
|
|
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
|
+
}
|
|
23
31
|
|
|
24
32
|
const notFound = {
|
|
25
33
|
ok: false,
|
|
26
34
|
failure: { code: 'NOT_FOUND', message: `no entity ${id}` },
|
|
27
35
|
} as const;
|
|
28
36
|
|
|
29
|
-
const
|
|
37
|
+
const owner = context.entityKinds.get(`${parsed.value.source}:${parsed.value.kind}`);
|
|
30
38
|
|
|
31
|
-
if (
|
|
39
|
+
if (owner === undefined) {
|
|
32
40
|
return notFound;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
const read = await context.store.
|
|
36
|
-
const entity = await
|
|
43
|
+
const read = await context.store.withConsumers([owner.consumer], async (db) => {
|
|
44
|
+
const entity = await db
|
|
37
45
|
.selectFrom('entity')
|
|
38
46
|
.selectAll()
|
|
39
47
|
.where('id', '=', id)
|
|
40
48
|
.executeTakeFirst();
|
|
41
49
|
|
|
42
50
|
if (entity === undefined) {
|
|
43
|
-
return
|
|
51
|
+
return null;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
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);
|
|
49
57
|
|
|
50
|
-
const result = await
|
|
58
|
+
const result = await sql`select * from ${table} where ${idColumn} = ${id}`.execute(db);
|
|
51
59
|
|
|
52
60
|
const [stored] = storedRowsSchema.parse(result).rows;
|
|
53
|
-
const declaration =
|
|
61
|
+
const declaration = owner.consumer.tables[owner.view.table];
|
|
54
62
|
|
|
55
|
-
invariant(
|
|
56
|
-
stored !== undefined && declaration !== undefined,
|
|
57
|
-
'an entity view row has a table row',
|
|
58
|
-
);
|
|
63
|
+
invariant(stored !== undefined && declaration !== undefined, 'an entity row has a table row');
|
|
59
64
|
|
|
60
|
-
const value:
|
|
65
|
+
const value: EntityOutput = {
|
|
61
66
|
entity,
|
|
67
|
+
consumer: owner.consumer.name,
|
|
68
|
+
table: owner.view.table,
|
|
62
69
|
row: buildEntityRow(declaration, stored),
|
|
63
|
-
aliases: [],
|
|
64
70
|
};
|
|
65
71
|
|
|
66
|
-
return
|
|
72
|
+
return value;
|
|
67
73
|
});
|
|
68
74
|
|
|
69
|
-
|
|
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 };
|
|
70
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
|
+
}
|