@pepys/api 0.0.1 → 0.1.1

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
@@ -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
- - The oRPC router and the app that serves it.
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.1",
3
+ "version": "0.1.1",
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
- "@orpc/contract": "1.16.1",
23
- "@orpc/server": "1.16.1",
24
- "@pepys/contract": "0.0.1",
25
- "@pepys/kit": "0.0.1",
26
- "@pepys/kysely-turso": "0.0.1",
27
- "@pepys/schema": "0.0.1",
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.1",
28
+ "@pepys/kit": "0.1.1",
29
+ "@pepys/kysely-turso": "0.1.1",
30
+ "@pepys/schema": "0.1.1",
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": "1.16.1",
40
- "@pepys/ingest": "0.0.1",
43
+ "@orpc/client": "2.0.0-beta.50",
44
+ "@pepys/testing": "0.1.1",
41
45
  "@zgeoff/std": "0.4.3"
42
46
  }
43
47
  }
@@ -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 handler = new RPCHandler(createRouter());
11
+ const router = createRouter(context.deployment.queries);
8
12
 
9
- return new Elysia().all('/rpc/*', { parse: 'none' }, async (route) => {
10
- const result = await handler.handle(route.request, { prefix: '/rpc', context });
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
- return result.matched ? result.response : new Response('not found', { status: 404 });
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
  }
@@ -1,68 +1,85 @@
1
- import type { ContractRouterClient } from '@orpc/contract';
2
- import { ORPCError, ValidationError, implement } from '@orpc/server';
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 { CoreContract } from '@pepys/contract';
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
- export function createRouter() {
9
- // oRPC validates the input before a procedure's own middleware by default, so a middleware
10
- // could not turn an input failure into the procedure's typed error.
11
- const api = implement(coreContract, {
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
- return api.router({
16
- get_entity: api.get_entity.handler(async (options) => {
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
- const buildNotFound = options.errors.NOT_FOUND;
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
- throw buildNotFound({ message: result.failure.message });
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
- neighbourhood: api.neighbourhood.handler(throwNotImplemented),
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
- export type APIClient = ContractRouterClient<CoreContract>;
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
- interface NotImplementedCall {
53
- errors: { NOT_IMPLEMENTED: () => Error };
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
- function throwNotImplemented(options: NotImplementedCall): never {
57
- const buildNotImplemented = options.errors.NOT_IMPLEMENTED;
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
- throw buildNotImplemented();
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
- function isMissingEvidence(error: unknown): boolean {
63
- return (
64
- error instanceof ORPCError &&
65
- error.cause instanceof ValidationError &&
66
- error.cause.issues.some((issue) => issue.path?.[0] === 'evidence')
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 { ok: true, value: { storeDirectory, port: port.value } };
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 type * from './change-feed/change-feed.ts';
5
- export type * from './principal/principal.ts';
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 type { EntityID, EntityIDParts, Result } from '@pepys/kit';
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
- interface ParsedEntityID {
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
- parsed: ParsedEntityID,
21
- ): Promise<Result<CoreOutputs['get_entity'], QueryFailure>> {
22
- const id = parsed.id;
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 found = findEntityConsumer(context.consumers, parsed.parts);
37
+ const owner = context.entityKinds.get(`${parsed.value.source}:${parsed.value.kind}`);
30
38
 
31
- if (found === undefined) {
39
+ if (owner === undefined) {
32
40
  return notFound;
33
41
  }
34
42
 
35
- const read = await context.store.withConsumer(found.consumer.name, async (transaction) => {
36
- const entity = await transaction
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 notFound;
51
+ return null;
44
52
  }
45
53
 
46
- const table = sql.table(found.entityView.table);
47
- const idColumn = sql.ref(found.entityView.id);
48
- const query = sql`select * from ${table} where ${idColumn} = ${id}`;
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 query.execute(transaction);
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 = found.consumer.tables[found.entityView.table];
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: CoreOutputs['get_entity'] = {
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 { ok: true, value } as const;
72
+ return value;
67
73
  });
68
74
 
69
- return read.ok ? read.value : notFound;
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
+ }