@pepys/schema 0.0.0-stage → 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 +32 -2
- package/package.json +25 -4
- package/src/consumer-execution/types.ts +62 -0
- package/src/consumer-manifest/consumer-build-manifest-schema.ts +198 -0
- package/src/consumer-manifest/consumer-manifest-cases.json +3760 -0
- package/src/consumer-manifest/types.ts +64 -0
- package/src/generated/kysely-db.ts +86 -0
- package/src/generators/collect-tables.ts +17 -0
- package/src/generators/format-typescript-row.ts +16 -0
- package/src/generators/generate-kysely-types.ts +21 -0
- package/src/generators/to-typescript-type.ts +19 -0
- package/src/index.ts +9 -0
- package/src/link-registry/is-entity-of-kind.ts +12 -0
- package/src/link-registry/link-registry.ts +65 -0
- package/src/service/make-shutdown-handler.ts +28 -0
- package/src/service/parse-port.ts +13 -0
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@pepys/schema</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@pepys/schema"><img src="https://img.shields.io/npm/v/@pepys/schema" 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 consumer manifest, for Bun: the types and the strict schema of `consumers.json`.
|
|
15
|
+
|
|
16
|
+
`pepys build` writes `consumers.json`, and the pepys services read it. A plug-in needs only
|
|
17
|
+
[`@pepys/kit`](../kit/README.md).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun add @pepys/schema
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Features
|
|
26
|
+
|
|
27
|
+
- `consumerBuildManifestSchema`: the strict schema of `consumers.json`, with its types.
|
|
28
|
+
- The types of a consumer's execution and of the link registry.
|
|
29
|
+
- The Kysely and Rust type generators for the store schema.
|
|
30
|
+
|
|
31
|
+
## Documentation
|
|
32
|
+
|
|
33
|
+
The [pepys docs](../../docs/README.md) hold the design of the manifest and the store.
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/schema",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The pepys consumer manifest: the types and the strict schema of consumers.json, with its shared case file",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zgeoff/pepys.git",
|
|
9
|
+
"directory": "packages/schema"
|
|
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
|
+
"@pepys/kit": "0.1.0",
|
|
23
|
+
"@zgeoff/std": "0.4.3",
|
|
24
|
+
"ts-pattern": "5.9.2",
|
|
25
|
+
"zod": "4.6.7"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BlobHash,
|
|
3
|
+
EntityID,
|
|
4
|
+
FactEnvelope,
|
|
5
|
+
FactID,
|
|
6
|
+
ScopeID,
|
|
7
|
+
StreamOffset,
|
|
8
|
+
VersionHash,
|
|
9
|
+
} from '@pepys/kit';
|
|
10
|
+
import type { LinkEnds, LinkRegistry } from '../link-registry/link-registry.ts';
|
|
11
|
+
|
|
12
|
+
export type ConsumerCallInput = FactInput | ScopeInput;
|
|
13
|
+
|
|
14
|
+
export interface FactInput {
|
|
15
|
+
mode: 'fact';
|
|
16
|
+
offset: StreamOffset;
|
|
17
|
+
fact: FactEnvelope<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ScopeInput {
|
|
21
|
+
mode: 'scope';
|
|
22
|
+
scope: ScopeID;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ReadRequest =
|
|
26
|
+
| { row: { table: string; key: string } }
|
|
27
|
+
| { scan: { table: string; index: string; prefix: readonly string[] } }
|
|
28
|
+
| { facts: EntityID }
|
|
29
|
+
| { blob: BlobHash };
|
|
30
|
+
|
|
31
|
+
export type ReadLimit = { mode: 'fact'; before: StreamOffset } | { mode: 'scope'; scope: ScopeID };
|
|
32
|
+
|
|
33
|
+
export interface ConsumerReads<Row> {
|
|
34
|
+
getRow: (table: string, key: string) => Row | undefined;
|
|
35
|
+
scanTable: (table: string, index: string, prefix: readonly string[]) => readonly Row[];
|
|
36
|
+
readEntityFacts: (entity: EntityID) => readonly FactEnvelope<unknown>[];
|
|
37
|
+
readBlob: (hash: BlobHash) => Uint8Array | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ConsumerLink<Registry extends LinkRegistry> = LinkEnds<Registry> & {
|
|
41
|
+
confidence: number;
|
|
42
|
+
evidence: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type FactOperation<Row, Registry extends LinkRegistry> =
|
|
46
|
+
| { upsert: { table: string; row: Row } }
|
|
47
|
+
| { delete: { table: string; key: string } }
|
|
48
|
+
| { link: ConsumerLink<Registry> }
|
|
49
|
+
| { unlink: LinkEnds<Registry> };
|
|
50
|
+
|
|
51
|
+
export type ScopeOperation<Row, Registry extends LinkRegistry> =
|
|
52
|
+
| { upsert: { table: string; row: Row } }
|
|
53
|
+
| { link: ConsumerLink<Registry> };
|
|
54
|
+
|
|
55
|
+
export type ConsumerOutcome<Operation> = { applied: readonly Operation[] } | { rejected: string };
|
|
56
|
+
|
|
57
|
+
export type Halt =
|
|
58
|
+
| { kind: 'rejected-fact'; offset: StreamOffset; fact: FactID; reason: string }
|
|
59
|
+
| { kind: 'rejected-scope'; scope: ScopeID; reason: string }
|
|
60
|
+
| { kind: 'refused-operation'; at: StreamOffset | ScopeID; reason: string };
|
|
61
|
+
|
|
62
|
+
export type Pins = Readonly<Record<string, VersionHash>>;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { VersionHash } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import type {
|
|
5
|
+
ConsumerBuildManifest,
|
|
6
|
+
ConsumerManifest,
|
|
7
|
+
ListedConsumer,
|
|
8
|
+
ManifestTable,
|
|
9
|
+
} from './types.ts';
|
|
10
|
+
|
|
11
|
+
// The host keeps these tables in every consumer's file.
|
|
12
|
+
const reservedTableNames = new Set([
|
|
13
|
+
'link',
|
|
14
|
+
'entity',
|
|
15
|
+
'offsets',
|
|
16
|
+
'change_log',
|
|
17
|
+
'applied_facts',
|
|
18
|
+
'scope_row',
|
|
19
|
+
'scope_fact',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const hashSchema = z.custom<VersionHash>(
|
|
23
|
+
(value) => typeof value === 'string' && /^[0-9a-f]{64}$/u.test(value),
|
|
24
|
+
'expected a SHA-256 as 64 lowercase hex digits',
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const nameSchema = z.string().min(1);
|
|
28
|
+
const symbolSchema = z.string().min(1);
|
|
29
|
+
const streamSchema = z.templateLiteral(['facts.', z.string(), '.', z.string()]);
|
|
30
|
+
const entityKindSchema = z.templateLiteral([z.string(), ':', z.string()]);
|
|
31
|
+
|
|
32
|
+
const inputSchema = z.discriminatedUnion('mode', [
|
|
33
|
+
z.strictObject({ mode: z.literal('fact'), streams: z.array(streamSchema).min(1) }),
|
|
34
|
+
z.strictObject({
|
|
35
|
+
mode: z.literal('scope'),
|
|
36
|
+
scopeKind: entityKindSchema,
|
|
37
|
+
streams: z.array(streamSchema).min(1),
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const readSchema = z.discriminatedUnion('kind', [
|
|
42
|
+
z.strictObject({
|
|
43
|
+
kind: z.literal('row'),
|
|
44
|
+
table: nameSchema,
|
|
45
|
+
length: symbolSchema,
|
|
46
|
+
into: symbolSchema,
|
|
47
|
+
}),
|
|
48
|
+
z.strictObject({ kind: z.literal('scope_facts'), length: symbolSchema, into: symbolSchema }),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const columnSchema = z.strictObject({
|
|
52
|
+
kind: z.enum(['text', 'integer', 'real', 'boolean', 'json', 'entityID', 'timestamp']),
|
|
53
|
+
nullable: z.boolean(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const tableSchema = z.strictObject({
|
|
57
|
+
columns: z.record(nameSchema, columnSchema),
|
|
58
|
+
primaryKey: z.array(nameSchema).min(1),
|
|
59
|
+
indexes: z.array(z.array(nameSchema).min(1)),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const linkTypeSchema = z.strictObject({
|
|
63
|
+
name: nameSchema,
|
|
64
|
+
from: z.array(entityKindSchema).min(1),
|
|
65
|
+
to: z.array(entityKindSchema).min(1),
|
|
66
|
+
hub: z.boolean(),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const manifestSchema = z
|
|
70
|
+
.strictObject({
|
|
71
|
+
name: nameSchema,
|
|
72
|
+
input: inputSchema,
|
|
73
|
+
exports: z.strictObject({ init: symbolSchema, apply: symbolSchema }),
|
|
74
|
+
imports: z.array(readSchema),
|
|
75
|
+
tables: z.record(nameSchema, tableSchema),
|
|
76
|
+
links: z.array(linkTypeSchema),
|
|
77
|
+
schema: z.string().min(1),
|
|
78
|
+
})
|
|
79
|
+
.superRefine((manifest, ctx) => {
|
|
80
|
+
for (const message of collectManifestProblems(manifest)) {
|
|
81
|
+
ctx.addIssue({ code: 'custom', message });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const listedConsumerSchema = z
|
|
86
|
+
.strictObject({
|
|
87
|
+
name: nameSchema,
|
|
88
|
+
module: hashSchema,
|
|
89
|
+
path: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(1)
|
|
92
|
+
.refine(
|
|
93
|
+
(path) => !path.startsWith('/') && !path.split('/').includes('..'),
|
|
94
|
+
'expected a path inside the folder of consumers.json',
|
|
95
|
+
),
|
|
96
|
+
pins: z.record(nameSchema, hashSchema),
|
|
97
|
+
version: hashSchema,
|
|
98
|
+
manifest: manifestSchema,
|
|
99
|
+
})
|
|
100
|
+
.refine(
|
|
101
|
+
(consumer) => consumer.name === consumer.manifest.name,
|
|
102
|
+
'expected the name of the entry to equal the name of its manifest',
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Strict at every level: a field that this schema does not know fails the parse, so a file from a
|
|
106
|
+
// newer build fails at once.
|
|
107
|
+
export const consumerBuildManifestSchema = z
|
|
108
|
+
.strictObject({
|
|
109
|
+
kit: z.literal(1),
|
|
110
|
+
deployment: nameSchema.nullable(),
|
|
111
|
+
consumers: z.array(listedConsumerSchema),
|
|
112
|
+
})
|
|
113
|
+
.superRefine((file, ctx) => {
|
|
114
|
+
for (const message of collectFileProblems(file.consumers)) {
|
|
115
|
+
ctx.addIssue({ code: 'custom', message });
|
|
116
|
+
}
|
|
117
|
+
}) satisfies z.ZodType<ConsumerBuildManifest>;
|
|
118
|
+
|
|
119
|
+
function collectManifestProblems(manifest: ConsumerManifest): string[] {
|
|
120
|
+
const symbols = [
|
|
121
|
+
manifest.exports.init,
|
|
122
|
+
manifest.exports.apply,
|
|
123
|
+
...manifest.imports.flatMap((read) => [read.length, read.into]),
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
return [
|
|
127
|
+
...collectReadProblems(manifest),
|
|
128
|
+
...collectRepeats(manifest.links.map((link) => link.name)).map(
|
|
129
|
+
(name) => `the link type ${name} is declared twice`,
|
|
130
|
+
),
|
|
131
|
+
...collectRepeats(symbols).map((symbol) => `the symbol ${symbol} is used twice`),
|
|
132
|
+
...Object.entries(manifest.tables).flatMap(([name, table]) =>
|
|
133
|
+
collectTableProblems(name, table),
|
|
134
|
+
),
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function collectReadProblems(manifest: ConsumerManifest): string[] {
|
|
139
|
+
return match(manifest.input.mode)
|
|
140
|
+
.with('scope', () => {
|
|
141
|
+
const [read, ...others] = manifest.imports;
|
|
142
|
+
|
|
143
|
+
return read?.kind === 'scope_facts' && others.length === 0
|
|
144
|
+
? []
|
|
145
|
+
: ['a scope consumer has exactly one read, of kind scope_facts'];
|
|
146
|
+
})
|
|
147
|
+
.with('fact', () =>
|
|
148
|
+
manifest.imports.flatMap((read) =>
|
|
149
|
+
match(read)
|
|
150
|
+
.with({ kind: 'scope_facts' }, () => ['a fact consumer reads rows only'])
|
|
151
|
+
.with({ kind: 'row' }, (row) =>
|
|
152
|
+
Object.hasOwn(manifest.tables, row.table)
|
|
153
|
+
? []
|
|
154
|
+
: [`the row read names ${row.table}, which is no table of the consumer`],
|
|
155
|
+
)
|
|
156
|
+
.exhaustive(),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
.exhaustive();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectRepeats(values: readonly string[]): string[] {
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
const repeats = new Set<string>();
|
|
165
|
+
|
|
166
|
+
for (const value of values) {
|
|
167
|
+
if (seen.has(value)) {
|
|
168
|
+
repeats.add(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
seen.add(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return [...repeats];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function collectTableProblems(name: string, table: ManifestTable): string[] {
|
|
178
|
+
const columns = [...table.primaryKey, ...table.indexes.flat()];
|
|
179
|
+
|
|
180
|
+
const missing = columns
|
|
181
|
+
.filter((column) => !Object.hasOwn(table.columns, column))
|
|
182
|
+
.map((column) => `the table ${name} has no column ${column}`);
|
|
183
|
+
|
|
184
|
+
return reservedTableNames.has(name)
|
|
185
|
+
? [`the table name ${name} is reserved for the host`, ...missing]
|
|
186
|
+
: missing;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectFileProblems(consumers: readonly ListedConsumer[]): string[] {
|
|
190
|
+
const tables = consumers.flatMap((consumer) => Object.keys(consumer.manifest.tables));
|
|
191
|
+
|
|
192
|
+
return [
|
|
193
|
+
...collectRepeats(consumers.map((consumer) => consumer.name)).map(
|
|
194
|
+
(name) => `the consumer ${name} is listed twice`,
|
|
195
|
+
),
|
|
196
|
+
...collectRepeats(tables).map((table) => `the table ${table} is in two consumers`),
|
|
197
|
+
];
|
|
198
|
+
}
|