@pepys/schema 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.
@@ -0,0 +1,64 @@
1
+ import type { ColumnKind, ConsumerInput, LinkTypeDeclaration, VersionHash } from '@pepys/kit';
2
+
3
+ // `consumers.json`, as `pepys build` writes it. Each entry is whole, so a kept version needs no file
4
+ // but its own entry.
5
+ export interface ConsumerBuildManifest {
6
+ readonly kit: 1;
7
+
8
+ // null for a plug-in built alone
9
+ readonly deployment: string | null;
10
+ readonly consumers: readonly ListedConsumer[];
11
+ }
12
+
13
+ export interface ConsumerVersion {
14
+ readonly name: string;
15
+
16
+ // the SHA-256 of the module's bytes
17
+ readonly module: VersionHash;
18
+
19
+ // enrichment champions; `{}` in v1
20
+ readonly pins: Readonly<Record<string, VersionHash>>;
21
+
22
+ // The SHA-256 of `module`, `pins` and `manifest` as JSON with sorted keys. The host names the
23
+ // version's folder by it and never computes it.
24
+ readonly version: VersionHash;
25
+ readonly manifest: ConsumerManifest;
26
+ }
27
+
28
+ export interface ListedConsumer extends ConsumerVersion {
29
+ // the module file, relative to `consumers.json`
30
+ readonly path: string;
31
+ }
32
+
33
+ export interface ConsumerManifest {
34
+ readonly name: string;
35
+ readonly input: ConsumerInput;
36
+
37
+ // the two symbols that the host calls
38
+ readonly exports: { readonly init: string; readonly apply: string };
39
+
40
+ // every read that the host serves the module, with its two symbols
41
+ readonly imports: readonly ManifestRead[];
42
+ readonly tables: Readonly<Record<string, ManifestTable>>;
43
+ readonly links: readonly LinkTypeDeclaration[];
44
+
45
+ // the consumer's tables, its indexes and its `entity` view, as the kit's SQL generator writes them
46
+ readonly schema: string;
47
+ }
48
+
49
+ // A `row` read serves a fact consumer one row of its own tables by key. A `scope_facts` read serves a
50
+ // scope consumer every fact of the scope's entity.
51
+ export type ManifestRead =
52
+ | { readonly kind: 'row'; readonly table: string; readonly length: string; readonly into: string }
53
+ | { readonly kind: 'scope_facts'; readonly length: string; readonly into: string };
54
+
55
+ export interface ManifestTable {
56
+ readonly columns: Readonly<Record<string, ManifestColumn>>;
57
+ readonly primaryKey: readonly string[];
58
+ readonly indexes: readonly (readonly string[])[];
59
+ }
60
+
61
+ export interface ManifestColumn {
62
+ readonly kind: ColumnKind;
63
+ readonly nullable: boolean;
64
+ }
@@ -0,0 +1,23 @@
1
+ // Generated by `bun run schema:generate`. Do not edit.
2
+
3
+ export interface GithubPullRequestRow {
4
+ id: string;
5
+ number: number;
6
+ repository_id: string;
7
+ title: string;
8
+ state: string;
9
+ is_draft: 0 | 1;
10
+ is_merged: 0 | 1;
11
+ author_id: string | null;
12
+ url: string;
13
+ merge_commit_sha: string | null;
14
+ created_at: string;
15
+ updated_at: string;
16
+ closed_at: string | null;
17
+ merged_at: string | null;
18
+ payload: unknown;
19
+ }
20
+
21
+ export interface DB {
22
+ github_pull_request: GithubPullRequestRow;
23
+ }
@@ -0,0 +1,17 @@
1
+ import type { ConsumerDeclaration, TableDeclaration } from '@pepys/kit';
2
+
3
+ export interface TableEntry {
4
+ consumer: string;
5
+ name: string;
6
+ table: TableDeclaration;
7
+ }
8
+
9
+ export function collectTables(consumers: readonly ConsumerDeclaration[]): TableEntry[] {
10
+ return consumers.flatMap((consumer) =>
11
+ Object.entries(consumer.tables).map(([name, table]) => ({
12
+ consumer: consumer.name,
13
+ name,
14
+ table,
15
+ })),
16
+ );
17
+ }
@@ -0,0 +1,16 @@
1
+ import type { TableDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import { toTypeScriptType } from './to-typescript-type.ts';
4
+ import type { TypeScriptTypeNames } from './to-typescript-type.ts';
5
+
6
+ export function formatTypeScriptRow(
7
+ tableName: string,
8
+ table: TableDeclaration,
9
+ names: TypeScriptTypeNames,
10
+ ): string {
11
+ const fields = Object.entries(table.columns).map(
12
+ ([name, column]) => ` ${name}: ${toTypeScriptType(column, names)};`,
13
+ );
14
+
15
+ return [`export interface ${toPascalCase(tableName)}Row {`, ...fields, '}'].join('\n');
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import { collectTables } from './collect-tables.ts';
4
+ import { formatTypeScriptRow } from './format-typescript-row.ts';
5
+
6
+ export function generateKyselyTypes(consumers: readonly ConsumerDeclaration[]): string {
7
+ const tables = collectTables(consumers);
8
+
9
+ // SQLite has no boolean type, so the driver reads a boolean column back as 0 or 1
10
+ const rows = tables.map((entry) =>
11
+ formatTypeScriptRow(entry.name, entry.table, { entityID: 'string', boolean: '0 | 1' }),
12
+ );
13
+
14
+ const database = [
15
+ 'export interface DB {',
16
+ ...tables.map((entry) => ` ${entry.name}: ${toPascalCase(entry.name)}Row;`),
17
+ '}',
18
+ ].join('\n');
19
+
20
+ return `${[...rows, database].join('\n\n')}\n`;
21
+ }
@@ -0,0 +1,269 @@
1
+ import type { ColumnDeclaration, ConsumerDeclaration, TableDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import { match } from 'ts-pattern';
4
+ import { toSnakeCase } from './to-snake-case.ts';
5
+
6
+ export function generateRustTypes(consumers: readonly ConsumerDeclaration[]): string {
7
+ const items = [
8
+ [
9
+ '#[derive(Debug, Clone, Copy, PartialEq, Eq)]',
10
+ 'pub struct ReadImport {',
11
+ " pub table: &'static str,",
12
+ " pub length: &'static str,",
13
+ " pub into: &'static str,",
14
+ '}',
15
+ ].join('\n'),
16
+ [
17
+ '#[derive(Debug, Clone, Copy, PartialEq, Eq)]',
18
+ 'pub enum ColumnKind {',
19
+ ' Text,',
20
+ ' Integer,',
21
+ ' Real,',
22
+ ' Boolean,',
23
+ ' Json,',
24
+ ' EntityId,',
25
+ ' Timestamp,',
26
+ '}',
27
+ ].join('\n'),
28
+ [
29
+ '#[derive(Debug, Clone, Copy, PartialEq, Eq)]',
30
+ 'pub struct ColumnInfo {',
31
+ " pub name: &'static str,",
32
+ ' pub kind: ColumnKind,',
33
+ ' pub nullable: bool,',
34
+ '}',
35
+ ].join('\n'),
36
+ [
37
+ '#[derive(Debug, Clone, Copy, PartialEq, Eq)]',
38
+ 'pub struct TableInfo {',
39
+ " pub name: &'static str,",
40
+ " pub columns: &'static [ColumnInfo],",
41
+ " pub primary_key: &'static [&'static str],",
42
+ '}',
43
+ ].join('\n'),
44
+ ...consumers.flatMap((consumer) => formatConsumer(consumer)),
45
+ ];
46
+
47
+ return `${items.join('\n\n')}\n`;
48
+ }
49
+
50
+ const derives = '#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]';
51
+
52
+ function formatConsumer(consumer: ConsumerDeclaration): string[] {
53
+ const tables = Object.entries(consumer.tables);
54
+ const consumerName = toPascalCase(consumer.name);
55
+
56
+ return [
57
+ ...tables.flatMap(([name, table]) => [
58
+ formatStruct(`${toPascalCase(name)}Row`, table, Object.keys(table.columns)),
59
+ formatStruct(`${toPascalCase(name)}Key`, table, table.primaryKey),
60
+ ]),
61
+ formatTableEnum(`${consumerName}Upsert`, tables, 'Row'),
62
+ formatTableEnum(`${consumerName}Delete`, tables, 'Key'),
63
+ [
64
+ derives,
65
+ '#[serde(rename_all = "snake_case")]',
66
+ `pub enum ${consumerName}Operation {`,
67
+ ` Upsert(Box<${consumerName}Upsert>),`,
68
+ ` Delete(${consumerName}Delete),`,
69
+ '}',
70
+ ].join('\n'),
71
+ formatTables(consumer),
72
+ formatReads(consumer),
73
+ ];
74
+ }
75
+
76
+ function formatStruct(
77
+ structName: string,
78
+ table: TableDeclaration,
79
+ columnNames: readonly string[],
80
+ ): string {
81
+ const fields = columnNames.flatMap((columnName) => {
82
+ const column = table.columns[columnName];
83
+
84
+ return column === undefined ? [] : formatField(columnName, column);
85
+ });
86
+
87
+ return [derives, `pub struct ${structName} {`, ...fields, '}'].join('\n');
88
+ }
89
+
90
+ function formatField(columnName: string, column: ColumnDeclaration): string[] {
91
+ const snakeName = toSnakeCase(columnName);
92
+ const identifier = toRustIdentifier(snakeName);
93
+ const rename = identifier.replace(/^r#/u, '') === columnName ? [] : [columnName];
94
+ const type = toRustType(column);
95
+
96
+ return [
97
+ ...rename.map((sqlName) => ` #[serde(rename = "${sqlName}")]`),
98
+ ` pub ${identifier}: ${type},`,
99
+ ];
100
+ }
101
+
102
+ const rawIdentifiers = new Set([
103
+ 'abstract',
104
+ 'as',
105
+ 'async',
106
+ 'await',
107
+ 'become',
108
+ 'box',
109
+ 'break',
110
+ 'const',
111
+ 'continue',
112
+ 'do',
113
+ 'dyn',
114
+ 'else',
115
+ 'enum',
116
+ 'extern',
117
+ 'false',
118
+ 'final',
119
+ 'fn',
120
+ 'for',
121
+ 'gen',
122
+ 'if',
123
+ 'impl',
124
+ 'in',
125
+ 'let',
126
+ 'loop',
127
+ 'macro',
128
+ 'match',
129
+ 'mod',
130
+ 'move',
131
+ 'mut',
132
+ 'override',
133
+ 'priv',
134
+ 'pub',
135
+ 'ref',
136
+ 'return',
137
+ 'static',
138
+ 'struct',
139
+ 'trait',
140
+ 'true',
141
+ 'try',
142
+ 'type',
143
+ 'typeof',
144
+ 'unsafe',
145
+ 'unsized',
146
+ 'use',
147
+ 'virtual',
148
+ 'where',
149
+ 'while',
150
+ 'yield',
151
+ ]);
152
+
153
+ const unescapableIdentifiers = new Set(['crate', 'self', 'Self', 'super']);
154
+
155
+ function toRustIdentifier(name: string): string {
156
+ if (unescapableIdentifiers.has(name)) {
157
+ return `${name}_`;
158
+ }
159
+
160
+ return rawIdentifiers.has(name) ? `r#${name}` : name;
161
+ }
162
+
163
+ function toRustType(column: ColumnDeclaration): string {
164
+ const type = match(column.kind)
165
+ .with('text', 'entityID', 'timestamp', () => 'String')
166
+ .with('integer', () => 'i64')
167
+ .with('real', () => 'f64')
168
+ .with('boolean', () => 'bool')
169
+ .with('json', () => 'serde_json::Value')
170
+ .exhaustive();
171
+
172
+ return column.nullable === true ? `Option<${type}>` : type;
173
+ }
174
+
175
+ function formatTableEnum(
176
+ enumName: string,
177
+ tables: readonly (readonly [string, TableDeclaration])[],
178
+ payloadSuffix: 'Row' | 'Key',
179
+ ): string {
180
+ const variants = tables.flatMap(([name]) => [
181
+ ` #[serde(rename = "${name}")]`,
182
+ ` ${toPascalCase(name)}(${toPascalCase(name)}${payloadSuffix}),`,
183
+ ]);
184
+
185
+ return [derives, `pub enum ${enumName} {`, ...variants, '}'].join('\n');
186
+ }
187
+
188
+ function formatTables(consumer: ConsumerDeclaration): string {
189
+ const tables = Object.entries(consumer.tables);
190
+ const constantName = `${toSnakeCase(consumer.name).toUpperCase()}_TABLES`;
191
+
192
+ const entries = tables.flatMap(([name, table]) => [
193
+ 'TableInfo {',
194
+ ` name: "${name}",`,
195
+ ' columns: &[',
196
+ ...Object.entries(table.columns).flatMap(([columnName, column]) =>
197
+ formatColumnInfo(columnName, column).map((line) => ` ${line}`),
198
+ ),
199
+ ' ],',
200
+ ` primary_key: &[${table.primaryKey.map((columnName) => `"${columnName}"`).join(', ')}],`,
201
+ '},',
202
+ ]);
203
+
204
+ // rustfmt hugs the brackets around a lone element and puts each of several on its own lines.
205
+ if (tables.length === 1) {
206
+ return [
207
+ `pub const ${constantName}: [TableInfo; 1] = [${entries[0]}`,
208
+ ...entries.slice(1, -1),
209
+ '}];',
210
+ ].join('\n');
211
+ }
212
+
213
+ return [
214
+ `pub const ${constantName}: [TableInfo; ${tables.length}] = [`,
215
+ ...entries.map((line) => ` ${line}`),
216
+ '];',
217
+ ].join('\n');
218
+ }
219
+
220
+ function formatColumnInfo(columnName: string, column: ColumnDeclaration): string[] {
221
+ const kind = match(column.kind)
222
+ .with('text', () => 'Text')
223
+ .with('integer', () => 'Integer')
224
+ .with('real', () => 'Real')
225
+ .with('boolean', () => 'Boolean')
226
+ .with('json', () => 'Json')
227
+ .with('entityID', () => 'EntityId')
228
+ .with('timestamp', () => 'Timestamp')
229
+ .exhaustive();
230
+
231
+ return [
232
+ 'ColumnInfo {',
233
+ ` name: "${columnName}",`,
234
+ ` kind: ColumnKind::${kind},`,
235
+ ` nullable: ${column.nullable === true ? 'true' : 'false'},`,
236
+ '},',
237
+ ];
238
+ }
239
+
240
+ function formatReads(consumer: ConsumerDeclaration): string {
241
+ const constantName = `${toSnakeCase(consumer.name).toUpperCase()}_READS`;
242
+ const declaration = `pub const ${constantName}: [ReadImport; ${consumer.reads.length}] =`;
243
+
244
+ if (consumer.reads.length === 0) {
245
+ return `${declaration} [];`;
246
+ }
247
+
248
+ const imports = consumer.reads.map((table) =>
249
+ [
250
+ 'ReadImport {',
251
+ ` table: "${table}",`,
252
+ ` length: "read${toPascalCase(table)}Length",`,
253
+ ` into: "read${toPascalCase(table)}Into",`,
254
+ '}',
255
+ ].join('\n'),
256
+ );
257
+
258
+ // rustfmt hugs the brackets around a lone element and puts each of several on its own lines.
259
+ if (imports.length === 1) {
260
+ return `${declaration} [${imports.join('')}];`;
261
+ }
262
+
263
+ const lines = imports
264
+ .map((entry) => `${entry},`)
265
+ .join('\n')
266
+ .split('\n');
267
+
268
+ return [`${declaration} [`, ...lines.map((line) => ` ${line}`), '];'].join('\n');
269
+ }
@@ -0,0 +1,6 @@
1
+ export function toSnakeCase(name: string): string {
2
+ return name
3
+ .replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, '$<lower>_$<upper>')
4
+ .replaceAll(/[^A-Za-z0-9]+/gu, '_')
5
+ .toLowerCase();
6
+ }
@@ -0,0 +1,19 @@
1
+ import type { ColumnDeclaration } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+
4
+ export interface TypeScriptTypeNames {
5
+ entityID: string;
6
+ boolean: string;
7
+ }
8
+
9
+ export function toTypeScriptType(column: ColumnDeclaration, names: TypeScriptTypeNames): string {
10
+ const type = match(column.kind)
11
+ .with('text', 'timestamp', () => 'string')
12
+ .with('integer', 'real', () => 'number')
13
+ .with('boolean', () => names.boolean)
14
+ .with('json', () => 'unknown')
15
+ .with('entityID', () => names.entityID)
16
+ .exhaustive();
17
+
18
+ return column.nullable === true && column.kind !== 'json' ? `${type} | null` : type;
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { consumerBuildManifestSchema } from './consumer-manifest/consumer-build-manifest-schema.ts';
2
+ export type * from './consumer-execution/types.ts';
3
+ export type * from './consumer-manifest/types.ts';
4
+ export { generateKyselyTypes } from './generators/generate-kysely-types.ts';
5
+ export { generateRustTypes } from './generators/generate-rust-types.ts';
6
+ export type { DB } from './generated/kysely-db.ts';
7
+ export { isEntityOfKind } from './link-registry/is-entity-of-kind.ts';
8
+ export type * from './link-registry/link-registry.ts';
9
+ export { makeShutdownHandler } from './service/make-shutdown-handler.ts';
10
+ export { parsePort } from './service/parse-port.ts';
@@ -0,0 +1,12 @@
1
+ import { parseEntityID } from '@pepys/kit';
2
+ import type { EntityID, EntityKind } from '@pepys/kit';
3
+ import type { KindedID } from './link-registry.ts';
4
+
5
+ export function isEntityOfKind<Kind extends EntityKind>(
6
+ id: EntityID,
7
+ kinds: readonly Kind[],
8
+ ): id is KindedID<Kind> {
9
+ const parsed = parseEntityID(id);
10
+
11
+ return parsed.ok && kinds.some((kind) => kind === `${parsed.value.source}:${parsed.value.kind}`);
12
+ }
@@ -0,0 +1,65 @@
1
+ import type {
2
+ ClientID,
3
+ EntityID,
4
+ EntityKind,
5
+ FactID,
6
+ LinkTypeDeclaration,
7
+ Reference,
8
+ } from '@pepys/kit';
9
+
10
+ // A plain string key, not a symbol: scriptc refuses the type-fest import that a symbol brand
11
+ // needs, and the brand a consumer generates must have the same shape.
12
+ export type KindedID<Kind extends EntityKind> = EntityID & { readonly '~entityKind': Kind };
13
+
14
+ export type LinkRegistry = Readonly<
15
+ Record<string, { readonly from: readonly EntityKind[]; readonly to: readonly EntityKind[] }>
16
+ >;
17
+
18
+ export type LinkRegistryOf<Declaration extends LinkTypeDeclaration> = {
19
+ readonly [Entry in Declaration as Entry['name']]: {
20
+ readonly from: Entry['from'];
21
+ readonly to: Entry['to'];
22
+ };
23
+ };
24
+
25
+ export type LinkTypeName<Registry extends LinkRegistry> = keyof Registry & string;
26
+
27
+ export type LinkTypeSources<
28
+ Registry extends LinkRegistry,
29
+ Name extends LinkTypeName<Registry>,
30
+ > = Registry[Name]['from'][number];
31
+
32
+ export type LinkTypeTargets<
33
+ Registry extends LinkRegistry,
34
+ Name extends LinkTypeName<Registry>,
35
+ > = Registry[Name]['to'][number];
36
+
37
+ export type LinkTarget<To extends EntityKind> =
38
+ | { kind: 'entity'; id: KindedID<To> }
39
+ | { kind: 'reference'; reference: Reference };
40
+
41
+ export type LinkOrigin =
42
+ | { kind: 'consumer'; name: string }
43
+ | { kind: 'manual'; client: ClientID; fact: FactID };
44
+
45
+ // A mapped union, not one object type: `Omit` over a union of link types loses the pairing of each
46
+ // type name with the kinds it allows.
47
+ export type LinkEnds<Registry extends LinkRegistry> = {
48
+ [Name in LinkTypeName<Registry>]: {
49
+ type: Name;
50
+ from: KindedID<LinkTypeSources<Registry, Name>>;
51
+ to: LinkTarget<LinkTypeTargets<Registry, Name>>;
52
+ };
53
+ }[LinkTypeName<Registry>];
54
+
55
+ export interface LinkDetails {
56
+ scope: string | null;
57
+ confidence: number;
58
+ evidence: string;
59
+ }
60
+
61
+ export type Link<Registry extends LinkRegistry> = LinkEnds<Registry> & LinkDetails;
62
+
63
+ export type EffectiveLink<Registry extends LinkRegistry> = Link<Registry> & { origin: LinkOrigin };
64
+
65
+ export type LinkRetraction<Registry extends LinkRegistry> = LinkEnds<Registry>;
@@ -0,0 +1,28 @@
1
+ interface ShutdownSteps {
2
+ stop: () => Promise<void>;
3
+ logError: (message: string, error: unknown) => void;
4
+ exit: (code: number) => void;
5
+ }
6
+
7
+ export function makeShutdownHandler(steps: ShutdownSteps): () => Promise<void> {
8
+ let shutdown: Promise<void> | undefined;
9
+
10
+ return () => {
11
+ shutdown ??= runShutdown(steps);
12
+
13
+ return shutdown;
14
+ };
15
+ }
16
+
17
+ async function runShutdown(steps: ShutdownSteps): Promise<void> {
18
+ try {
19
+ await steps.stop();
20
+ } catch (error: unknown) {
21
+ steps.logError('shutdown failed', error);
22
+ steps.exit(1);
23
+
24
+ return;
25
+ }
26
+
27
+ steps.exit(0);
28
+ }
@@ -0,0 +1,13 @@
1
+ import type { Result } from '@pepys/kit';
2
+
3
+ const digitsPattern = /^[0-9]+$/u;
4
+
5
+ export function parsePort(text: string): Result<number, { reason: 'not-a-port' }> {
6
+ const port = digitsPattern.test(text) ? Number(text) : Number.NaN;
7
+
8
+ if (port >= 1 && port <= 65_535) {
9
+ return { ok: true, value: port };
10
+ }
11
+
12
+ return { ok: false, failure: { reason: 'not-a-port' } };
13
+ }