@pepys/api 0.0.0-stage → 0.0.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 +30 -2
- package/package.json +41 -4
- package/src/app/create-app.ts +14 -0
- package/src/app/create-router.ts +68 -0
- package/src/change-feed/change-feed.ts +33 -0
- package/src/config/read-api-config.ts +26 -0
- package/src/index.ts +11 -0
- package/src/principal/principal.ts +10 -0
- package/src/queries/get-entity/build-entity-row.ts +23 -0
- package/src/queries/get-entity/find-entity-consumer.ts +23 -0
- package/src/queries/get-entity/read-entity.ts +70 -0
- package/src/queries/types.ts +12 -0
- package/src/store-reader/create-store-reader.ts +123 -0
- package/src/store-reader/open-consumer-database.ts +6 -0
- package/src/store-reader/read-change-operations.ts +35 -0
- package/src/store-reader/read-offsets.ts +28 -0
- package/src/store-reader/read-pull-requests.ts +13 -0
- package/src/store-reader/store-reader.ts +37 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bun add @pepys/api
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- The oRPC router and the app that serves it.
|
|
27
|
+
- The store reader over the host's Turso files.
|
|
28
|
+
|
|
29
|
+
## Documentation
|
|
30
|
+
|
|
31
|
+
The [pepys docs](../../docs/README.md) hold the design of the API and the store.
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/api",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
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
|
+
"@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",
|
|
28
|
+
"@tursodatabase/database": "0.8.3",
|
|
29
|
+
"elysia": "2.0.0-rc.1",
|
|
30
|
+
"exact-mirror": "1.2.7",
|
|
31
|
+
"kysely": "0.29.8",
|
|
32
|
+
"openapi-types": "12.1.3",
|
|
33
|
+
"tiny-invariant": "1.3.3",
|
|
34
|
+
"ts-pattern": "5.9.2",
|
|
35
|
+
"typebox": "1.4.4",
|
|
36
|
+
"zod": "4.6.7"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@orpc/client": "1.16.1",
|
|
40
|
+
"@pepys/ingest": "0.0.1",
|
|
41
|
+
"@zgeoff/std": "0.4.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { RPCHandler } from '@orpc/server/fetch';
|
|
2
|
+
import { Elysia } from 'elysia';
|
|
3
|
+
import type { QueryContext } from '../queries/types.ts';
|
|
4
|
+
import { createRouter } from './create-router.ts';
|
|
5
|
+
|
|
6
|
+
export function createApp(context: QueryContext) {
|
|
7
|
+
const handler = new RPCHandler(createRouter());
|
|
8
|
+
|
|
9
|
+
return new Elysia().all('/rpc/*', { parse: 'none' }, async (route) => {
|
|
10
|
+
const result = await handler.handle(route.request, { prefix: '/rpc', context });
|
|
11
|
+
|
|
12
|
+
return result.matched ? result.response : new Response('not found', { status: 404 });
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ContractRouterClient } from '@orpc/contract';
|
|
2
|
+
import { ORPCError, ValidationError, implement } from '@orpc/server';
|
|
3
|
+
import { coreContract } from '@pepys/contract';
|
|
4
|
+
import type { CoreContract } from '@pepys/contract';
|
|
5
|
+
import { readEntity } from '../queries/get-entity/read-entity.ts';
|
|
6
|
+
import type { QueryContext } from '../queries/types.ts';
|
|
7
|
+
|
|
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>();
|
|
14
|
+
|
|
15
|
+
return api.router({
|
|
16
|
+
get_entity: api.get_entity.handler(async (options) => {
|
|
17
|
+
const result = await readEntity(options.context, options.input.id);
|
|
18
|
+
|
|
19
|
+
const buildNotFound = options.errors.NOT_FOUND;
|
|
20
|
+
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
throw buildNotFound({ message: result.failure.message });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return result.value;
|
|
26
|
+
}),
|
|
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
|
+
}
|
|
49
|
+
|
|
50
|
+
export type APIClient = ContractRouterClient<CoreContract>;
|
|
51
|
+
|
|
52
|
+
interface NotImplementedCall {
|
|
53
|
+
errors: { NOT_IMPLEMENTED: () => Error };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function throwNotImplemented(options: NotImplementedCall): never {
|
|
57
|
+
const buildNotImplemented = options.errors.NOT_IMPLEMENTED;
|
|
58
|
+
|
|
59
|
+
throw buildNotImplemented();
|
|
60
|
+
}
|
|
61
|
+
|
|
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
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { StreamOffset } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
export type ChangeMessage =
|
|
4
|
+
| {
|
|
5
|
+
kind: 'change';
|
|
6
|
+
type: string;
|
|
7
|
+
key: string;
|
|
8
|
+
value: unknown;
|
|
9
|
+
oldValue: unknown;
|
|
10
|
+
operation: 'insert' | 'update' | 'delete';
|
|
11
|
+
}
|
|
12
|
+
| { kind: 'snapshot-start' }
|
|
13
|
+
| { kind: 'snapshot-end' };
|
|
14
|
+
|
|
15
|
+
export interface FeedStream {
|
|
16
|
+
consumer: string;
|
|
17
|
+
generation: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FeedPosition = StreamOffset | -1 | 'now';
|
|
21
|
+
|
|
22
|
+
export type FeedRead =
|
|
23
|
+
| { kind: 'messages'; messages: readonly ChangeMessage[]; next: StreamOffset; atTail: boolean }
|
|
24
|
+
| { kind: 'closed'; currentGeneration: number }
|
|
25
|
+
| { kind: 'gone'; snapshotAt: StreamOffset };
|
|
26
|
+
|
|
27
|
+
export interface ChangeFeed {
|
|
28
|
+
read: (
|
|
29
|
+
stream: FeedStream,
|
|
30
|
+
from: FeedPosition,
|
|
31
|
+
mode: 'catch-up' | 'long-poll',
|
|
32
|
+
) => Promise<FeedRead>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import { parsePort } from '@pepys/schema';
|
|
3
|
+
|
|
4
|
+
export interface APIConfig {
|
|
5
|
+
storeDirectory: string;
|
|
6
|
+
port: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function readAPIConfig(
|
|
10
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
11
|
+
): Result<APIConfig, string[]> {
|
|
12
|
+
const storeDirectory = environment['PEPYS_STORE_DIR'] ?? '';
|
|
13
|
+
const portText = environment['PEPYS_API_PORT'] ?? '';
|
|
14
|
+
const port = portText === '' ? ({ ok: true, value: 8081 } as const) : parsePort(portText);
|
|
15
|
+
|
|
16
|
+
const problems = [
|
|
17
|
+
...(storeDirectory === '' ? ['PEPYS_STORE_DIR is not set'] : []),
|
|
18
|
+
...(port.ok ? [] : ['PEPYS_API_PORT is not a port number']),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
if (!port.ok || problems.length > 0) {
|
|
22
|
+
return { ok: false, failure: problems };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { ok: true, value: { storeDirectory, port: port.value } };
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { createRouter } from './app/create-router.ts';
|
|
2
|
+
export type { APIClient } from './app/create-router.ts';
|
|
3
|
+
export { createApp } from './app/create-app.ts';
|
|
4
|
+
export type * from './change-feed/change-feed.ts';
|
|
5
|
+
export type * from './principal/principal.ts';
|
|
6
|
+
export { readAPIConfig } from './config/read-api-config.ts';
|
|
7
|
+
export { createStoreReader } from './store-reader/create-store-reader.ts';
|
|
8
|
+
export { openConsumerDatabase } from './store-reader/open-consumer-database.ts';
|
|
9
|
+
export { readChangeOperations } from './store-reader/read-change-operations.ts';
|
|
10
|
+
export { readOffsets } from './store-reader/read-offsets.ts';
|
|
11
|
+
export { readPullRequests } from './store-reader/read-pull-requests.ts';
|
|
@@ -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,23 @@
|
|
|
1
|
+
import type { ConsumerDeclaration, EntityIDParts, EntityViewDeclaration } from '@pepys/kit';
|
|
2
|
+
|
|
3
|
+
export interface EntityConsumer {
|
|
4
|
+
consumer: ConsumerDeclaration;
|
|
5
|
+
entityView: EntityViewDeclaration;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function findEntityConsumer(
|
|
9
|
+
consumers: readonly ConsumerDeclaration[],
|
|
10
|
+
parts: EntityIDParts,
|
|
11
|
+
): EntityConsumer | undefined {
|
|
12
|
+
const kind = `${parts.source}:${parts.kind}`;
|
|
13
|
+
|
|
14
|
+
for (const consumer of consumers) {
|
|
15
|
+
const entityView = consumer.entityViews.find((view) => view.kind === kind);
|
|
16
|
+
|
|
17
|
+
if (entityView !== undefined) {
|
|
18
|
+
return { consumer, entityView };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CoreOutputs } from '@pepys/contract';
|
|
2
|
+
import type { EntityID, EntityIDParts, Result } from '@pepys/kit';
|
|
3
|
+
import { sql } from 'kysely';
|
|
4
|
+
import invariant from 'tiny-invariant';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { QueryContext, QueryFailure } from '../types.ts';
|
|
7
|
+
import { buildEntityRow } from './build-entity-row.ts';
|
|
8
|
+
import { findEntityConsumer } from './find-entity-consumer.ts';
|
|
9
|
+
|
|
10
|
+
const storedRowSchema = z.record(z.string(), z.unknown());
|
|
11
|
+
const storedRowsSchema = z.object({ rows: z.array(storedRowSchema) });
|
|
12
|
+
|
|
13
|
+
interface ParsedEntityID {
|
|
14
|
+
id: EntityID;
|
|
15
|
+
parts: EntityIDParts;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readEntity(
|
|
19
|
+
context: QueryContext,
|
|
20
|
+
parsed: ParsedEntityID,
|
|
21
|
+
): Promise<Result<CoreOutputs['get_entity'], QueryFailure>> {
|
|
22
|
+
const id = parsed.id;
|
|
23
|
+
|
|
24
|
+
const notFound = {
|
|
25
|
+
ok: false,
|
|
26
|
+
failure: { code: 'NOT_FOUND', message: `no entity ${id}` },
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const found = findEntityConsumer(context.consumers, parsed.parts);
|
|
30
|
+
|
|
31
|
+
if (found === undefined) {
|
|
32
|
+
return notFound;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const read = await context.store.withConsumer(found.consumer.name, async (transaction) => {
|
|
36
|
+
const entity = await transaction
|
|
37
|
+
.selectFrom('entity')
|
|
38
|
+
.selectAll()
|
|
39
|
+
.where('id', '=', id)
|
|
40
|
+
.executeTakeFirst();
|
|
41
|
+
|
|
42
|
+
if (entity === undefined) {
|
|
43
|
+
return notFound;
|
|
44
|
+
}
|
|
45
|
+
|
|
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}`;
|
|
49
|
+
|
|
50
|
+
const result = await query.execute(transaction);
|
|
51
|
+
|
|
52
|
+
const [stored] = storedRowsSchema.parse(result).rows;
|
|
53
|
+
const declaration = found.consumer.tables[found.entityView.table];
|
|
54
|
+
|
|
55
|
+
invariant(
|
|
56
|
+
stored !== undefined && declaration !== undefined,
|
|
57
|
+
'an entity view row has a table row',
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const value: CoreOutputs['get_entity'] = {
|
|
61
|
+
entity,
|
|
62
|
+
row: buildEntityRow(declaration, stored),
|
|
63
|
+
aliases: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { ok: true, value } as const;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return read.ok ? read.value : notFound;
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ConsumerDeclaration } from '@pepys/kit';
|
|
2
|
+
import type { StoreReader } from '../store-reader/store-reader.ts';
|
|
3
|
+
|
|
4
|
+
export interface QueryContext {
|
|
5
|
+
store: StoreReader;
|
|
6
|
+
consumers: readonly ConsumerDeclaration[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface QueryFailure {
|
|
10
|
+
code: 'NOT_FOUND';
|
|
11
|
+
message: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { Result } from '@pepys/kit';
|
|
4
|
+
import { createTursoDialect } from '@pepys/kysely-turso';
|
|
5
|
+
import type { Database } from '@tursodatabase/database';
|
|
6
|
+
import { Kysely } from 'kysely';
|
|
7
|
+
import type { Transaction } from 'kysely';
|
|
8
|
+
import type { ConsumerDB, StoreReader } from './store-reader.ts';
|
|
9
|
+
|
|
10
|
+
interface OpenConsumer {
|
|
11
|
+
target: string;
|
|
12
|
+
database: Kysely<ConsumerDB>;
|
|
13
|
+
readers: number;
|
|
14
|
+
isRetired: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface StoreReaderOptions {
|
|
18
|
+
storeDirectory: string;
|
|
19
|
+
openDatabase: (path: string) => Promise<Database>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createStoreReader(options: StoreReaderOptions): StoreReader {
|
|
23
|
+
const open = new Map<string, OpenConsumer>();
|
|
24
|
+
const retired = new Set<OpenConsumer>();
|
|
25
|
+
|
|
26
|
+
const withConsumer = async <Value>(
|
|
27
|
+
consumerName: string,
|
|
28
|
+
work: (transaction: Transaction<ConsumerDB>) => Promise<Value>,
|
|
29
|
+
): Promise<Result<Value, { reason: 'no-current-version' }>> => {
|
|
30
|
+
// the link swings to a new version directory when `pepys up` swaps, and the old file stays
|
|
31
|
+
const target = await readCurrentTarget(join(options.storeDirectory, consumerName, 'current'));
|
|
32
|
+
|
|
33
|
+
if (target === null) {
|
|
34
|
+
return { ok: false, failure: { reason: 'no-current-version' } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const opened = openConsumer(consumerName, target);
|
|
38
|
+
const consumer = opened.consumer;
|
|
39
|
+
|
|
40
|
+
consumer.readers += 1;
|
|
41
|
+
|
|
42
|
+
if (opened.replaced !== undefined) {
|
|
43
|
+
await destroyIfDrained(opened.replaced);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
// one transaction keeps every read of a call on one connection and one snapshot
|
|
48
|
+
return { ok: true, value: await consumer.database.transaction().execute(work) };
|
|
49
|
+
} finally {
|
|
50
|
+
consumer.readers -= 1;
|
|
51
|
+
|
|
52
|
+
await destroyIfDrained(consumer);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const openConsumer = (
|
|
57
|
+
consumerName: string,
|
|
58
|
+
target: string,
|
|
59
|
+
): { consumer: OpenConsumer; replaced?: OpenConsumer } => {
|
|
60
|
+
const existing = open.get(consumerName);
|
|
61
|
+
|
|
62
|
+
if (existing?.target === target) {
|
|
63
|
+
return { consumer: existing };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const consumer: OpenConsumer = {
|
|
67
|
+
target,
|
|
68
|
+
database: new Kysely<ConsumerDB>({
|
|
69
|
+
dialect: createTursoDialect({
|
|
70
|
+
database: () => options.openDatabase(join(target, 'consumer.db')),
|
|
71
|
+
}),
|
|
72
|
+
}),
|
|
73
|
+
readers: 0,
|
|
74
|
+
isRetired: false,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
open.set(consumerName, consumer);
|
|
78
|
+
|
|
79
|
+
if (existing === undefined) {
|
|
80
|
+
return { consumer };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
existing.isRetired = true;
|
|
84
|
+
|
|
85
|
+
retired.add(existing);
|
|
86
|
+
|
|
87
|
+
return { consumer, replaced: existing };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const destroyIfDrained = async (consumer: OpenConsumer): Promise<void> => {
|
|
91
|
+
if (consumer.isRetired && consumer.readers === 0 && retired.delete(consumer)) {
|
|
92
|
+
await consumer.database.destroy();
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const close = async (): Promise<void> => {
|
|
97
|
+
const consumers = [...open.values(), ...retired];
|
|
98
|
+
|
|
99
|
+
open.clear();
|
|
100
|
+
retired.clear();
|
|
101
|
+
|
|
102
|
+
// a destroy waits for the reads still running on the database
|
|
103
|
+
await Promise.all(consumers.map((consumer) => consumer.database.destroy()));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return { withConsumer, close };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function readCurrentTarget(currentLink: string): Promise<string | null> {
|
|
110
|
+
try {
|
|
111
|
+
return await realpath(currentLink);
|
|
112
|
+
} catch (error: unknown) {
|
|
113
|
+
if (isMissingFileError(error)) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isMissingFileError(error: unknown): boolean {
|
|
122
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
123
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { StoreReader } from './store-reader.ts';
|
|
4
|
+
|
|
5
|
+
interface ChangeOperation {
|
|
6
|
+
key: string;
|
|
7
|
+
operation: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// each change log row holds one batch as a JSON list of State Protocol messages
|
|
11
|
+
const headersSchema = z.object({ operation: z.string() });
|
|
12
|
+
const changesSchema = z.array(z.object({ key: z.string(), headers: headersSchema }));
|
|
13
|
+
|
|
14
|
+
export async function readChangeOperations(
|
|
15
|
+
store: StoreReader,
|
|
16
|
+
consumerName: string,
|
|
17
|
+
): Promise<Result<ChangeOperation[], { reason: 'no-current-version' }>> {
|
|
18
|
+
const result = await store.withConsumer(consumerName, (transaction) =>
|
|
19
|
+
transaction.selectFrom('change_log').select('changes').orderBy('batch').execute(),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!result.ok) {
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const operations = result.value.flatMap((row) => {
|
|
27
|
+
const changes: unknown = JSON.parse(row.changes);
|
|
28
|
+
|
|
29
|
+
return changesSchema
|
|
30
|
+
.parse(changes)
|
|
31
|
+
.map((change) => ({ key: change.key, operation: change.headers.operation }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return { ok: true, value: operations };
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import type { StoreReader } from './store-reader.ts';
|
|
3
|
+
|
|
4
|
+
interface Offset {
|
|
5
|
+
stream: string;
|
|
6
|
+
lastSeqNum: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function readOffsets(
|
|
10
|
+
store: StoreReader,
|
|
11
|
+
consumerName: string,
|
|
12
|
+
): Promise<Result<Offset[], { reason: 'no-current-version' }>> {
|
|
13
|
+
const result = await store.withConsumer(consumerName, (transaction) =>
|
|
14
|
+
transaction
|
|
15
|
+
.selectFrom('offsets')
|
|
16
|
+
.select(['stream', 'last_seq_num'])
|
|
17
|
+
.orderBy('stream')
|
|
18
|
+
.execute(),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const offsets = result.value.map((row) => ({ stream: row.stream, lastSeqNum: row.last_seq_num }));
|
|
26
|
+
|
|
27
|
+
return { ok: true, value: offsets };
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import type { Selectable } from 'kysely';
|
|
3
|
+
import type { ConsumerDB, StoreReader } from './store-reader.ts';
|
|
4
|
+
|
|
5
|
+
type PullRequestRow = Selectable<ConsumerDB['github_pull_request']>;
|
|
6
|
+
|
|
7
|
+
export function readPullRequests(
|
|
8
|
+
store: StoreReader,
|
|
9
|
+
): Promise<Result<PullRequestRow[], { reason: 'no-current-version' }>> {
|
|
10
|
+
return store.withConsumer('github_pull_request', (transaction) =>
|
|
11
|
+
transaction.selectFrom('github_pull_request').selectAll().orderBy('id').execute(),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Result } from '@pepys/kit';
|
|
2
|
+
import type { DB } from '@pepys/schema';
|
|
3
|
+
import type { Transaction } from 'kysely';
|
|
4
|
+
|
|
5
|
+
interface EntityViewRow {
|
|
6
|
+
id: string;
|
|
7
|
+
kind: string;
|
|
8
|
+
title: string;
|
|
9
|
+
url: string;
|
|
10
|
+
actor: string | null;
|
|
11
|
+
updated_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// the host creates these two tables in every consumer file, beside the consumer's own
|
|
15
|
+
interface OffsetRow {
|
|
16
|
+
stream: string;
|
|
17
|
+
last_seq_num: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ChangeLogRow {
|
|
21
|
+
batch: number;
|
|
22
|
+
changes: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ConsumerDB = DB & {
|
|
26
|
+
entity: EntityViewRow;
|
|
27
|
+
offsets: OffsetRow;
|
|
28
|
+
change_log: ChangeLogRow;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface StoreReader {
|
|
32
|
+
withConsumer: <Value>(
|
|
33
|
+
consumerName: string,
|
|
34
|
+
work: (transaction: Transaction<ConsumerDB>) => Promise<Value>,
|
|
35
|
+
) => Promise<Result<Value, { reason: 'no-current-version' }>>;
|
|
36
|
+
close: () => Promise<void>;
|
|
37
|
+
}
|