@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.
@@ -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
+ }
@@ -1,12 +1,19 @@
1
- import type { ConsumerDeclaration } from '@pepys/kit';
2
- import type { StoreReader } from '../store-reader/store-reader.ts';
1
+ import type { CheckedDeployment, LinkRegistry, StoreReader } from '@pepys/kit';
2
+ import type { EntityKindIndex } from './entity-kinds/build-entity-kind-index.ts';
3
3
 
4
4
  export interface QueryContext {
5
5
  store: StoreReader;
6
- consumers: readonly ConsumerDeclaration[];
6
+ deployment: CheckedDeployment;
7
+ entityKinds: EntityKindIndex;
8
+ linkRegistry: LinkRegistry;
7
9
  }
8
10
 
9
- export interface QueryFailure {
10
- code: 'NOT_FOUND';
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;
11
18
  message: string;
12
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
+ }
@@ -1,116 +1,173 @@
1
1
  import { realpath } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import type { Result } from '@pepys/kit';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import type { ConsumerDeclaration, ConsumersDB, StoreReader } from '@pepys/kit';
4
4
  import { createTursoDialect } from '@pepys/kysely-turso';
5
+ import { connect } from '@tursodatabase/database';
5
6
  import type { Database } from '@tursodatabase/database';
6
7
  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
- }
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';
16
11
 
17
12
  interface StoreReaderOptions {
18
13
  storeDirectory: string;
19
- openDatabase: (path: string) => Promise<Database>;
20
14
  }
21
15
 
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
- }
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
+ }
45
24
 
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;
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>();
51
31
 
52
- await destroyIfDrained(consumer);
53
- }
54
- };
32
+ const holdKeeper = (file: AttachedFile): FileKeeper => {
33
+ const held = keepers.get(file.consumer.name);
55
34
 
56
- const openConsumer = (
57
- consumerName: string,
58
- target: string,
59
- ): { consumer: OpenConsumer; replaced?: OpenConsumer } => {
60
- const existing = open.get(consumerName);
35
+ if (held?.path === file.path) {
36
+ held.readers += 1;
61
37
 
62
- if (existing?.target === target) {
63
- return { consumer: existing };
38
+ return held;
64
39
  }
65
40
 
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,
41
+ const keeper: FileKeeper = {
42
+ path: file.path,
43
+ database: connect(file.path, { readonly: true, experimental: ['multiprocess_wal'] }),
44
+ readers: 1,
74
45
  isRetired: false,
75
46
  };
76
47
 
77
- open.set(consumerName, consumer);
48
+ keepers.set(file.consumer.name, keeper);
78
49
 
79
- if (existing === undefined) {
80
- return { consumer };
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);
81
55
  }
82
56
 
83
- existing.isRetired = true;
57
+ return keeper;
58
+ };
84
59
 
85
- retired.add(existing);
60
+ const releaseKeeper = async (keeper: FileKeeper): Promise<void> => {
61
+ keeper.readers -= 1;
86
62
 
87
- return { consumer, replaced: existing };
88
- };
63
+ if (keeper.isRetired && keeper.readers === 0 && retired.delete(keeper)) {
64
+ const database = await keeper.database;
89
65
 
90
- const destroyIfDrained = async (consumer: OpenConsumer): Promise<void> => {
91
- if (consumer.isRetired && consumer.readers === 0 && retired.delete(consumer)) {
92
- await consumer.database.destroy();
66
+ await database.close();
93
67
  }
94
68
  };
95
69
 
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()));
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
+ },
104
146
  };
147
+ }
105
148
 
106
- return { withConsumer, close };
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
+ );
107
162
  }
108
163
 
109
- async function readCurrentTarget(currentLink: string): Promise<string | null> {
164
+ async function readRealPath(path: string): Promise<string | null> {
110
165
  try {
111
- return await realpath(currentLink);
166
+ const resolved = await realpath(path);
167
+
168
+ return resolved;
112
169
  } catch (error: unknown) {
113
- if (isMissingFileError(error)) {
170
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
114
171
  return null;
115
172
  }
116
173
 
@@ -118,6 +175,13 @@ async function readCurrentTarget(currentLink: string): Promise<string | null> {
118
175
  }
119
176
  }
120
177
 
121
- function isMissingFileError(error: unknown): boolean {
122
- return error instanceof Error && 'code' in error && error.code === 'ENOENT';
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('"', '""')}"`;
123
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
+ }
@@ -1,29 +1,38 @@
1
- import type { Result } from '@pepys/kit';
1
+ import type { ConsumerDeclaration, Result, StoreReader, StoreReaderFailure } from '@pepys/kit';
2
+ import { sql } from 'kysely';
2
3
  import { z } from 'zod';
3
- import type { StoreReader } from './store-reader.ts';
4
4
 
5
5
  interface ChangeOperation {
6
6
  key: string;
7
7
  operation: string;
8
8
  }
9
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
+
10
18
  // 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 }));
19
+ const changesSchema = z.array(changeSchema);
13
20
 
14
21
  export async function readChangeOperations(
15
22
  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(),
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),
20
29
  );
21
30
 
22
31
  if (!result.ok) {
23
32
  return result;
24
33
  }
25
34
 
26
- const operations = result.value.flatMap((row) => {
35
+ const operations = changeLogRowsSchema.parse(result.value).rows.flatMap((row) => {
27
36
  const changes: unknown = JSON.parse(row.changes);
28
37
 
29
38
  return changesSchema