@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
|
@@ -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
|
+
}
|
package/src/queries/types.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
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
|
-
|
|
6
|
+
deployment: CheckedDeployment;
|
|
7
|
+
entityKinds: EntityKindIndex;
|
|
8
|
+
linkRegistry: LinkRegistry;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
export interface QueryFailure
|
|
10
|
-
|
|
11
|
+
export interface QueryFailure<
|
|
12
|
+
Code extends 'NOT_FOUND' | 'BAD_REQUEST' | 'SERVICE_UNAVAILABLE' =
|
|
13
|
+
| 'NOT_FOUND'
|
|
14
|
+
| 'BAD_REQUEST'
|
|
15
|
+
| 'SERVICE_UNAVAILABLE',
|
|
16
|
+
> {
|
|
17
|
+
code: Code;
|
|
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 {
|
|
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
|
|
8
|
-
import
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
};
|
|
32
|
+
const holdKeeper = (file: AttachedFile): FileKeeper => {
|
|
33
|
+
const held = keepers.get(file.consumer.name);
|
|
55
34
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
return { consumer: existing };
|
|
38
|
+
return held;
|
|
64
39
|
}
|
|
65
40
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
database:
|
|
69
|
-
|
|
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
|
-
|
|
48
|
+
keepers.set(file.consumer.name, keeper);
|
|
78
49
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
57
|
+
return keeper;
|
|
58
|
+
};
|
|
84
59
|
|
|
85
|
-
|
|
60
|
+
const releaseKeeper = async (keeper: FileKeeper): Promise<void> => {
|
|
61
|
+
keeper.readers -= 1;
|
|
86
62
|
|
|
87
|
-
|
|
88
|
-
|
|
63
|
+
if (keeper.isRetired && keeper.readers === 0 && retired.delete(keeper)) {
|
|
64
|
+
const database = await keeper.database;
|
|
89
65
|
|
|
90
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
|
164
|
+
async function readRealPath(path: string): Promise<string | null> {
|
|
110
165
|
try {
|
|
111
|
-
|
|
166
|
+
const resolved = await realpath(path);
|
|
167
|
+
|
|
168
|
+
return resolved;
|
|
112
169
|
} catch (error: unknown) {
|
|
113
|
-
if (
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
): Promise<Result<ChangeOperation[],
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|