@pepys/kit 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 +63 -2
- package/package.json +34 -4
- package/src/constants.ts +1 -0
- package/src/consumers/define-consumer.ts +20 -0
- package/src/consumers/types.ts +48 -0
- package/src/delivery-checks/build-delivery-proof.ts +12 -0
- package/src/delivery-checks/is-delivery-authentic.ts +49 -0
- package/src/deployments/check-deployment.ts +444 -0
- package/src/deployments/define-deployment.ts +7 -0
- package/src/deployments/define-plugin.ts +56 -0
- package/src/deployments/list-receive-routes.ts +27 -0
- package/src/deployments/types.ts +110 -0
- package/src/entity-ids/build-entity-id.ts +16 -0
- package/src/entity-ids/entity-id-failure.ts +6 -0
- package/src/entity-ids/entity-id-parts.ts +5 -0
- package/src/entity-ids/fact-kinds.ts +3 -0
- package/src/entity-ids/find-entity-id-failure.ts +25 -0
- package/src/entity-ids/parse-entity-id.ts +27 -0
- package/src/fact-envelope-cases.json +464 -0
- package/src/facts/build-enrichment-fact-id.ts +15 -0
- package/src/facts/build-event-fact-id.ts +11 -0
- package/src/facts/build-fact-hash.ts +6 -0
- package/src/facts/build-manual-fact-id.ts +7 -0
- package/src/facts/build-observation-fact-id.ts +15 -0
- package/src/facts/build-webhook-fact-id.ts +11 -0
- package/src/facts/parse-fact-envelope.ts +44 -0
- package/src/facts/parse-stream-name.ts +33 -0
- package/src/generators/build-payload-shape.ts +75 -0
- package/src/generators/collect-view-kinds.ts +8 -0
- package/src/generators/format-column-type.ts +16 -0
- package/src/generators/format-entity-ids.ts +46 -0
- package/src/generators/format-fact-reader.ts +194 -0
- package/src/generators/format-literal.ts +13 -0
- package/src/generators/format-model-contract.ts +23 -0
- package/src/generators/format-operations.ts +109 -0
- package/src/generators/format-payload-shape.ts +181 -0
- package/src/generators/format-property-access.ts +6 -0
- package/src/generators/format-property-name.ts +6 -0
- package/src/generators/format-row-readers.ts +116 -0
- package/src/generators/generate-boundary-glue.ts +60 -0
- package/src/generators/generate-sqlite-schema.ts +78 -0
- package/src/generators/is-identifier.ts +5 -0
- package/src/generators/quote-sql-identifier.ts +3 -0
- package/src/generators/to-entity-id-type-name.ts +6 -0
- package/src/generators/to-pascal-case.ts +7 -0
- package/src/generators.ts +4 -0
- package/src/ids/types.ts +25 -0
- package/src/index.ts +36 -0
- package/src/link-types/define-link-type.ts +9 -0
- package/src/link-types/types.ts +15 -0
- package/src/links/find-link-problem.ts +53 -0
- package/src/links/link-check-cases.json +274 -0
- package/src/links/types.ts +22 -0
- package/src/queries/define-query.ts +14 -0
- package/src/queries/types.ts +56 -0
- package/src/send/create-fact-batch-sender.ts +143 -0
- package/src/send/types.ts +25 -0
- package/src/send.ts +2 -0
- package/src/sources/define-fact-family.ts +10 -0
- package/src/sources/define-source.ts +7 -0
- package/src/sources/read-fact-batch.ts +114 -0
- package/src/sources/receive-fact-batch.ts +11 -0
- package/src/sources/types.ts +154 -0
- package/src/store-reader/store-reader.ts +76 -0
- package/src/tables/define-table.ts +13 -0
- package/src/tables/table-row.ts +20 -0
- package/src/types.ts +47 -0
package/README.md
CHANGED
|
@@ -1,3 +1,64 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>@pepys/kit</h1>
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@pepys/kit"><img src="https://img.shields.io/npm/v/@pepys/kit" 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
|
+
<a href="../../docs/design/v1.md">pepys v1</a>
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
Declare a pepys plug-in or deployment as plain data.
|
|
16
|
+
|
|
17
|
+
A plug-in names its fact families, sources, consumers, tables, links and queries with the kit's
|
|
18
|
+
`define*` functions. The kit checks a deployment, generates each consumer's glue and SQL schema, and
|
|
19
|
+
signs and sends a fact batch. The package ships its TypeScript source and runs on Bun.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @pepys/kit
|
|
25
|
+
bun add zod@"$(npm view @pepys/kit dependencies.zod)"
|
|
26
|
+
bun add -d @types/bun typescript
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Install the zod version that the kit pins, as the second line does: a schema from another zod
|
|
30
|
+
version fails the type check of a `define*` call.
|
|
31
|
+
|
|
32
|
+
Test the plug-in with [`@pepys/testing`](../testing/README.md), and build it with
|
|
33
|
+
[`@pepys/cli`](../cli/README.md).
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- `@pepys/kit`: the `define*` declarations and the deployment check.
|
|
38
|
+
- `@pepys/kit/generators`: the SQL schema and glue generators.
|
|
39
|
+
- `@pepys/kit/send`: the client that signs and sends a fact batch.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Your compiler checks the kit's source with your `tsconfig.json`, so set at least these options:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"compilerOptions": {
|
|
48
|
+
"target": "ESNext",
|
|
49
|
+
"lib": ["ESNext"],
|
|
50
|
+
"module": "Preserve",
|
|
51
|
+
"moduleResolution": "bundler",
|
|
52
|
+
"allowImportingTsExtensions": true,
|
|
53
|
+
"verbatimModuleSyntax": true,
|
|
54
|
+
"noEmit": true,
|
|
55
|
+
"strict": true,
|
|
56
|
+
"skipLibCheck": true,
|
|
57
|
+
"types": ["bun"]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
The [pepys docs](../../docs/README.md) hold the design of the kit and of each part of pepys.
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/kit",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Declare a pepys plug-in or deployment: fact families, sources, consumers, tables, links and queries",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zgeoff/pepys.git",
|
|
9
|
+
"directory": "packages/kit"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/test-utils"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./generators": "./src/generators.ts",
|
|
20
|
+
"./send": "./src/send.ts"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@zgeoff/std": "0.4.3",
|
|
27
|
+
"kysely": "0.29.8",
|
|
28
|
+
"ts-pattern": "5.9.2",
|
|
29
|
+
"type-fest": "5.10.0",
|
|
30
|
+
"zod": "4.6.7"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@octokit/webhooks": "14.2.0",
|
|
34
|
+
"tiny-invariant": "1.3.3"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const kitVersion = 1;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LinkTypeDeclaration } from '../link-types/types.ts';
|
|
2
|
+
import type { TableDeclaration } from '../types.ts';
|
|
3
|
+
import type { ConsumerDeclaration, EntityViewDeclaration } from './types.ts';
|
|
4
|
+
|
|
5
|
+
type ConsumerInputOf<
|
|
6
|
+
Name extends string,
|
|
7
|
+
Tables extends Readonly<Record<string, TableDeclaration>>,
|
|
8
|
+
LinkType extends LinkTypeDeclaration,
|
|
9
|
+
> = Omit<ConsumerDeclaration<Name, Tables, LinkType>, 'entityViews' | 'reads'> & {
|
|
10
|
+
readonly entityViews: readonly EntityViewDeclaration<NoInfer<keyof Tables & string>>[];
|
|
11
|
+
readonly reads: readonly NoInfer<keyof Tables & string>[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function defineConsumer<
|
|
15
|
+
const Name extends string,
|
|
16
|
+
const Tables extends Readonly<Record<string, TableDeclaration>>,
|
|
17
|
+
const LinkType extends LinkTypeDeclaration = never,
|
|
18
|
+
>(consumer: ConsumerInputOf<Name, Tables, LinkType>): ConsumerDeclaration<Name, Tables, LinkType> {
|
|
19
|
+
return consumer;
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EntityKind, LinkTypeDeclaration } from '../link-types/types.ts';
|
|
2
|
+
import type { DeclaredStreamName, TableDeclaration } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
// The same shape in the declaration and in the consumer manifest, so the build copies it whole.
|
|
5
|
+
export type ConsumerInput =
|
|
6
|
+
| { readonly mode: 'fact'; readonly streams: readonly DeclaredStreamName[] }
|
|
7
|
+
|
|
8
|
+
// A fact on one of `streams` whose entity is of `scopeKind` marks that entity's scope dirty.
|
|
9
|
+
| {
|
|
10
|
+
readonly mode: 'scope';
|
|
11
|
+
readonly scopeKind: EntityKind;
|
|
12
|
+
readonly streams: readonly DeclaredStreamName[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface EntityViewDeclaration<TableName extends string = string> {
|
|
16
|
+
readonly table: TableName;
|
|
17
|
+
readonly kind: EntityKind;
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly url: string | null;
|
|
21
|
+
readonly actor: string | null;
|
|
22
|
+
readonly updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The entry file is a scriptc library with the model contract: it exports a model type, a message
|
|
26
|
+
// type, `init` and `update`, beside the consumer's own export. scriptc attests no other shape.
|
|
27
|
+
export interface ConsumerDeclaration<
|
|
28
|
+
Name extends string = string,
|
|
29
|
+
Tables extends Readonly<Record<string, TableDeclaration>> = Readonly<
|
|
30
|
+
Record<string, TableDeclaration>
|
|
31
|
+
>,
|
|
32
|
+
LinkType extends LinkTypeDeclaration = LinkTypeDeclaration,
|
|
33
|
+
> {
|
|
34
|
+
readonly name: Name;
|
|
35
|
+
readonly entry: URL;
|
|
36
|
+
readonly input: ConsumerInput;
|
|
37
|
+
readonly tables: Tables;
|
|
38
|
+
|
|
39
|
+
// One view per table whose rows are entities. `defineConsumer` keeps the table names to the
|
|
40
|
+
// consumer's own; a key type here would make a declaration fit no wider consumer type.
|
|
41
|
+
readonly entityViews: readonly EntityViewDeclaration[];
|
|
42
|
+
|
|
43
|
+
// own tables whose rows the module reads by key; fact mode only
|
|
44
|
+
readonly reads: readonly string[];
|
|
45
|
+
|
|
46
|
+
// the link types the consumer may write
|
|
47
|
+
readonly links: readonly LinkType[];
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { DeliveryCheck } from '../sources/types.ts';
|
|
4
|
+
|
|
5
|
+
// The value of the check's header for one body: `sha256=<hex>` of the HMAC for a signed path, and
|
|
6
|
+
// `Bearer <secret>` for a token path.
|
|
7
|
+
export function buildDeliveryProof(check: DeliveryCheck, secret: string, body: Uint8Array): string {
|
|
8
|
+
return match(check.kind)
|
|
9
|
+
.with('hmac-sha256', () => `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`)
|
|
10
|
+
.with('token', () => `Bearer ${secret}`)
|
|
11
|
+
.exhaustive();
|
|
12
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { DeliveryCheck } from '../sources/types.ts';
|
|
4
|
+
|
|
5
|
+
// The check of one delivery, on the body as sent. Ingest calls it for every receive path, and the
|
|
6
|
+
// sender's proof must pass it. An empty secret passes nothing, so a missing variable never opens a
|
|
7
|
+
// route.
|
|
8
|
+
export function isDeliveryAuthentic(
|
|
9
|
+
check: DeliveryCheck,
|
|
10
|
+
secret: string,
|
|
11
|
+
headers: Headers,
|
|
12
|
+
body: Uint8Array,
|
|
13
|
+
): boolean {
|
|
14
|
+
const proof = headers.get(check.header);
|
|
15
|
+
|
|
16
|
+
if (secret === '' || proof === null) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return match(check.kind)
|
|
21
|
+
.with('hmac-sha256', () => isSignatureAuthentic(secret, proof, body))
|
|
22
|
+
.with('token', () => isTokenAuthentic(secret, proof))
|
|
23
|
+
.exhaustive();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const signaturePattern = /^sha256=(?<hex>[0-9a-fA-F]{64})$/u;
|
|
27
|
+
|
|
28
|
+
function isSignatureAuthentic(secret: string, signature: string, body: Uint8Array): boolean {
|
|
29
|
+
const hex = signaturePattern.exec(signature)?.groups?.['hex'];
|
|
30
|
+
|
|
31
|
+
if (hex === undefined) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const expected = createHmac('sha256', secret).update(body).digest();
|
|
36
|
+
|
|
37
|
+
return timingSafeEqual(Buffer.from(hex, 'hex'), expected);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const bearerPrefix = 'Bearer ';
|
|
41
|
+
|
|
42
|
+
// Both sides are hashed first, so the compare takes the same time for a token of any length.
|
|
43
|
+
function isTokenAuthentic(secret: string, proof: string): boolean {
|
|
44
|
+
const token = proof.startsWith(bearerPrefix) ? proof.slice(bearerPrefix.length) : proof;
|
|
45
|
+
const tokenDigest = createHash('sha256').update(token).digest();
|
|
46
|
+
const secretDigest = createHash('sha256').update(secret).digest();
|
|
47
|
+
|
|
48
|
+
return timingSafeEqual(tokenDigest, secretDigest);
|
|
49
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { kitVersion } from '../constants.ts';
|
|
2
|
+
import type { ConsumerDeclaration } from '../consumers/types.ts';
|
|
3
|
+
import { factKinds } from '../entity-ids/fact-kinds.ts';
|
|
4
|
+
import type { EntityKind, LinkTypeDeclaration } from '../link-types/types.ts';
|
|
5
|
+
import type { FactFamily, SourceDefinition } from '../sources/types.ts';
|
|
6
|
+
import type { Result } from '../types.ts';
|
|
7
|
+
import type {
|
|
8
|
+
CheckedDeployment,
|
|
9
|
+
Deployment,
|
|
10
|
+
DeploymentFailure,
|
|
11
|
+
DuplicateOf,
|
|
12
|
+
Plugin,
|
|
13
|
+
SettingValue,
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
|
|
16
|
+
export function checkDeployment(
|
|
17
|
+
deployment: Deployment,
|
|
18
|
+
): Result<CheckedDeployment, readonly DeploymentFailure[]> {
|
|
19
|
+
const parts = collectParts(deployment.plugins);
|
|
20
|
+
const settings = collectSettings(deployment);
|
|
21
|
+
|
|
22
|
+
const failures = [
|
|
23
|
+
...collectKitFailures(deployment.plugins),
|
|
24
|
+
...collectDuplicates(deployment.plugins, parts),
|
|
25
|
+
...collectReservedNames(deployment.plugins, parts),
|
|
26
|
+
...collectConsumerFailures(deployment.plugins, parts),
|
|
27
|
+
...collectMissingConsumers(deployment.plugins, parts),
|
|
28
|
+
...settings.failures,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
if (failures.length > 0) {
|
|
32
|
+
return { ok: false, failure: failures };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
value: {
|
|
38
|
+
name: deployment.name,
|
|
39
|
+
sources: parts.sources.map((owned) => owned.item),
|
|
40
|
+
consumers: parts.consumers.map((owned) => owned.item),
|
|
41
|
+
queries: deployment.plugins.flatMap((plugin) => plugin.queries),
|
|
42
|
+
entityKinds: [...new Set(deployment.plugins.flatMap((plugin) => plugin.entityKinds))],
|
|
43
|
+
linkTypes: collectUniqueLinkTypes(deployment.plugins),
|
|
44
|
+
settings: settings.values,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// An item of the deployment with the name of the plug-in that brings it.
|
|
50
|
+
interface Owned<Item> {
|
|
51
|
+
plugin: string;
|
|
52
|
+
item: Item;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DeploymentParts {
|
|
56
|
+
sources: Owned<SourceDefinition>[];
|
|
57
|
+
families: Owned<FactFamily>[];
|
|
58
|
+
consumers: Owned<ConsumerDeclaration>[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectParts(plugins: readonly Plugin[]): DeploymentParts {
|
|
62
|
+
const parts: DeploymentParts = { sources: [], families: [], consumers: [] };
|
|
63
|
+
|
|
64
|
+
for (const plugin of plugins) {
|
|
65
|
+
for (const source of plugin.sources) {
|
|
66
|
+
parts.sources.push({ plugin: plugin.name, item: source });
|
|
67
|
+
|
|
68
|
+
for (const family of source.families) {
|
|
69
|
+
parts.families.push({ plugin: plugin.name, item: family });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const consumer of plugin.consumers) {
|
|
74
|
+
parts.consumers.push({ plugin: plugin.name, item: consumer });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return parts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type SettingValues = Record<string, Record<string, SettingValue>>;
|
|
82
|
+
|
|
83
|
+
function collectSettings(deployment: Deployment): {
|
|
84
|
+
values: SettingValues;
|
|
85
|
+
failures: DeploymentFailure[];
|
|
86
|
+
} {
|
|
87
|
+
const given = deployment.settings ?? {};
|
|
88
|
+
const values: SettingValues = {};
|
|
89
|
+
const failures: DeploymentFailure[] = [];
|
|
90
|
+
|
|
91
|
+
for (const name of Object.keys(given)) {
|
|
92
|
+
const plugin = deployment.plugins.find((candidate) => candidate.name === name);
|
|
93
|
+
|
|
94
|
+
if (plugin === undefined) {
|
|
95
|
+
failures.push(buildSettingsFailure(name, 'the deployment has no plug-in of this name'));
|
|
96
|
+
} else if (plugin.settings === undefined) {
|
|
97
|
+
failures.push(buildSettingsFailure(name, 'the plug-in declares no settings'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const plugin of deployment.plugins) {
|
|
102
|
+
if (plugin.settings === undefined) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const parsed = plugin.settings.schema.safeParse({
|
|
107
|
+
...plugin.settings.defaults,
|
|
108
|
+
...given[plugin.name],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
const issues = parsed.error.issues.map(
|
|
113
|
+
(issue) => `${issue.path.join('.')}: ${issue.message}`,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
failures.push({ kind: 'invalid-settings', plugin: plugin.name, issues });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const entries = Object.entries(parsed.data);
|
|
121
|
+
|
|
122
|
+
const flat = entries.filter((entry): entry is [string, SettingValue] =>
|
|
123
|
+
isSettingValue(entry[1]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (flat.length < entries.length) {
|
|
127
|
+
failures.push(
|
|
128
|
+
buildSettingsFailure(plugin.name, 'a setting must be a string, a number or a boolean'),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
values[plugin.name] = Object.fromEntries(flat);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { values, failures };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildSettingsFailure(plugin: string, issue: string): DeploymentFailure {
|
|
141
|
+
return { kind: 'invalid-settings', plugin, issues: [issue] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isSettingValue(value: unknown): value is SettingValue {
|
|
145
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function collectKitFailures(plugins: readonly Plugin[]): DeploymentFailure[] {
|
|
149
|
+
return plugins
|
|
150
|
+
.filter((plugin) => plugin.kit !== kitVersion)
|
|
151
|
+
.map((plugin) => ({ kind: 'kit-version', plugin: plugin.name, kit: plugin.kit }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface Named {
|
|
155
|
+
name: string;
|
|
156
|
+
plugin: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function collectDuplicates(
|
|
160
|
+
plugins: readonly Plugin[],
|
|
161
|
+
parts: DeploymentParts,
|
|
162
|
+
): DeploymentFailure[] {
|
|
163
|
+
const names: Record<DuplicateOf, Named[]> = {
|
|
164
|
+
plugin: plugins.map((plugin) => ({ name: plugin.name, plugin: plugin.name })),
|
|
165
|
+
source: parts.sources.map((owned) => ({ name: owned.item.name, plugin: owned.plugin })),
|
|
166
|
+
stream: parts.families.map((owned) => ({ name: owned.item.stream, plugin: owned.plugin })),
|
|
167
|
+
route: [],
|
|
168
|
+
'fact-type': [],
|
|
169
|
+
consumer: parts.consumers.map((owned) => ({ name: owned.item.name, plugin: owned.plugin })),
|
|
170
|
+
table: [],
|
|
171
|
+
query: [],
|
|
172
|
+
'link-type': collectLinkTypeShapes(parts.consumers),
|
|
173
|
+
'entity-view-kind': [],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
for (const owned of parts.sources) {
|
|
177
|
+
const path = owned.item.live;
|
|
178
|
+
|
|
179
|
+
if (path?.kind === 'receive') {
|
|
180
|
+
for (const route of [path.route, ...(path.ignoredRoutes ?? [])]) {
|
|
181
|
+
names.route.push({ name: route, plugin: owned.plugin });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const owned of parts.families) {
|
|
187
|
+
for (const type of Object.keys(owned.item.types)) {
|
|
188
|
+
names['fact-type'].push({ name: type, plugin: owned.plugin });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const owned of parts.consumers) {
|
|
193
|
+
for (const table of Object.keys(owned.item.tables)) {
|
|
194
|
+
names.table.push({ name: table, plugin: owned.plugin });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const view of owned.item.entityViews) {
|
|
198
|
+
names['entity-view-kind'].push({ name: view.kind, plugin: owned.plugin });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const plugin of plugins) {
|
|
203
|
+
for (const query of plugin.queries) {
|
|
204
|
+
names.query.push({ name: query.name, plugin: plugin.name });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const failures: DeploymentFailure[] = [];
|
|
209
|
+
|
|
210
|
+
for (const [of, entries] of Object.entries(names)) {
|
|
211
|
+
failures.push(...collectRepeatedNames(of, entries));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return failures;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// One entry per distinct shape of a name, so a link type that two consumers share counts once.
|
|
218
|
+
function collectLinkTypeShapes(consumers: readonly Owned<ConsumerDeclaration>[]): Named[] {
|
|
219
|
+
const shapes = new Map<string, Named>();
|
|
220
|
+
|
|
221
|
+
for (const owned of consumers) {
|
|
222
|
+
for (const linkType of owned.item.links) {
|
|
223
|
+
const shape = JSON.stringify([linkType.name, linkType.from, linkType.to, linkType.hub]);
|
|
224
|
+
|
|
225
|
+
if (!shapes.has(shape)) {
|
|
226
|
+
shapes.set(shape, { name: linkType.name, plugin: owned.plugin });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return [...shapes.values()];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function collectRepeatedNames(of: string, entries: readonly Named[]): DeploymentFailure[] {
|
|
235
|
+
const failures: DeploymentFailure[] = [];
|
|
236
|
+
|
|
237
|
+
for (const [name, group] of Map.groupBy(entries, (entry) => entry.name)) {
|
|
238
|
+
if (group.length > 1 && isDuplicateOf(of)) {
|
|
239
|
+
failures.push({ kind: 'duplicate', of, name, plugins: group.map((entry) => entry.plugin) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return failures;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const duplicateOfValues: ReadonlySet<string> = new Set<DuplicateOf>([
|
|
247
|
+
'plugin',
|
|
248
|
+
'source',
|
|
249
|
+
'stream',
|
|
250
|
+
'route',
|
|
251
|
+
'fact-type',
|
|
252
|
+
'consumer',
|
|
253
|
+
'table',
|
|
254
|
+
'query',
|
|
255
|
+
'link-type',
|
|
256
|
+
'entity-view-kind',
|
|
257
|
+
]);
|
|
258
|
+
|
|
259
|
+
function isDuplicateOf(text: string): text is DuplicateOf {
|
|
260
|
+
return duplicateOfValues.has(text);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const coreQueryNames: ReadonlySet<string> = new Set(['get_entity', 'neighbourhood']);
|
|
264
|
+
|
|
265
|
+
// the host's own tables and views in every consumer file
|
|
266
|
+
const hostTableNames: ReadonlySet<string> = new Set([
|
|
267
|
+
'link',
|
|
268
|
+
'link_effective',
|
|
269
|
+
'entity',
|
|
270
|
+
'offsets',
|
|
271
|
+
'change_log',
|
|
272
|
+
'applied_facts',
|
|
273
|
+
'scope_row',
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
function collectReservedNames(
|
|
277
|
+
plugins: readonly Plugin[],
|
|
278
|
+
parts: DeploymentParts,
|
|
279
|
+
): DeploymentFailure[] {
|
|
280
|
+
const failures: DeploymentFailure[] = [];
|
|
281
|
+
|
|
282
|
+
const reservedSources: ReadonlySet<string> = new Set(factKinds);
|
|
283
|
+
|
|
284
|
+
for (const owned of parts.sources) {
|
|
285
|
+
if (reservedSources.has(owned.item.name)) {
|
|
286
|
+
failures.push({ kind: 'reserved-source-name', source: owned.item.name });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const query of plugins.flatMap((plugin) => plugin.queries)) {
|
|
291
|
+
if (coreQueryNames.has(query.name)) {
|
|
292
|
+
failures.push({ kind: 'reserved-query-name', query: query.name });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const owned of parts.consumers) {
|
|
297
|
+
for (const table of Object.keys(owned.item.tables)) {
|
|
298
|
+
if (hostTableNames.has(table)) {
|
|
299
|
+
failures.push({ kind: 'reserved-table-name', table, consumer: owned.item.name });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return failures;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function collectConsumerFailures(
|
|
308
|
+
plugins: readonly Plugin[],
|
|
309
|
+
parts: DeploymentParts,
|
|
310
|
+
): DeploymentFailure[] {
|
|
311
|
+
const streams: ReadonlySet<string> = new Set(parts.families.map((owned) => owned.item.stream));
|
|
312
|
+
const kinds: ReadonlySet<string> = new Set(plugins.flatMap((plugin) => plugin.entityKinds));
|
|
313
|
+
|
|
314
|
+
const failures: DeploymentFailure[] = [];
|
|
315
|
+
|
|
316
|
+
for (const owned of parts.consumers) {
|
|
317
|
+
const consumer = owned.item;
|
|
318
|
+
|
|
319
|
+
if (consumer.input.mode === 'scope' && consumer.reads.length > 0) {
|
|
320
|
+
failures.push({ kind: 'scope-reads', consumer: consumer.name });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const stream of consumer.input.streams.filter((name) => !streams.has(name))) {
|
|
324
|
+
failures.push({ kind: 'missing-stream', stream, consumer: consumer.name });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const use of collectKindUses(consumer)) {
|
|
328
|
+
if (!kinds.has(use.entityKind)) {
|
|
329
|
+
failures.push({ kind: 'unknown-entity-kind', ...use, plugin: owned.plugin });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
failures.push(...collectTableFailures(consumer));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return failures;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
interface KindUse {
|
|
340
|
+
entityKind: EntityKind;
|
|
341
|
+
usedBy: string;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function collectKindUses(consumer: ConsumerDeclaration): KindUse[] {
|
|
345
|
+
const uses = new Map<string, KindUse>();
|
|
346
|
+
|
|
347
|
+
const addUse = (entityKind: EntityKind, usedBy: string): void => {
|
|
348
|
+
uses.set(`${entityKind}\n${usedBy}`, { entityKind, usedBy });
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (consumer.input.mode === 'scope') {
|
|
352
|
+
addUse(consumer.input.scopeKind, consumer.name);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const linkType of consumer.links) {
|
|
356
|
+
for (const entityKind of [...linkType.from, ...linkType.to]) {
|
|
357
|
+
addUse(entityKind, linkType.name);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return [...uses.values()];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function collectTableFailures(consumer: ConsumerDeclaration): DeploymentFailure[] {
|
|
365
|
+
const failures: DeploymentFailure[] = [];
|
|
366
|
+
|
|
367
|
+
const addUnknownColumns = (
|
|
368
|
+
table: string,
|
|
369
|
+
columns: readonly string[],
|
|
370
|
+
usedBy: 'primary-key' | 'index' | 'entity-view',
|
|
371
|
+
): void => {
|
|
372
|
+
const declared = consumer.tables[table]?.columns ?? {};
|
|
373
|
+
|
|
374
|
+
for (const column of columns.filter((name) => !Object.hasOwn(declared, name))) {
|
|
375
|
+
failures.push({ kind: 'unknown-column', consumer: consumer.name, table, column, usedBy });
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
for (const [table, declaration] of Object.entries(consumer.tables)) {
|
|
380
|
+
addUnknownColumns(table, declaration.primaryKey, 'primary-key');
|
|
381
|
+
|
|
382
|
+
addUnknownColumns(
|
|
383
|
+
table,
|
|
384
|
+
declaration.indexes.flatMap((index) => index.columns),
|
|
385
|
+
'index',
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const table of consumer.reads.filter((name) => !Object.hasOwn(consumer.tables, name))) {
|
|
390
|
+
failures.push({ kind: 'unknown-table', consumer: consumer.name, table, usedBy: 'reads' });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const view of consumer.entityViews) {
|
|
394
|
+
if (Object.hasOwn(consumer.tables, view.table)) {
|
|
395
|
+
const columns = [view.id, view.title, view.url, view.actor, view.updatedAt];
|
|
396
|
+
|
|
397
|
+
addUnknownColumns(
|
|
398
|
+
view.table,
|
|
399
|
+
columns.filter((column) => column !== null),
|
|
400
|
+
'entity-view',
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
failures.push({
|
|
404
|
+
kind: 'unknown-table',
|
|
405
|
+
consumer: consumer.name,
|
|
406
|
+
table: view.table,
|
|
407
|
+
usedBy: 'entity-view',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return failures;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function collectMissingConsumers(
|
|
416
|
+
plugins: readonly Plugin[],
|
|
417
|
+
parts: DeploymentParts,
|
|
418
|
+
): DeploymentFailure[] {
|
|
419
|
+
const names: ReadonlySet<string> = new Set(parts.consumers.map((owned) => owned.item.name));
|
|
420
|
+
|
|
421
|
+
const failures: DeploymentFailure[] = [];
|
|
422
|
+
|
|
423
|
+
for (const plugin of plugins) {
|
|
424
|
+
for (const query of plugin.queries) {
|
|
425
|
+
for (const consumer of query.reads.filter((read) => !names.has(read.name))) {
|
|
426
|
+
failures.push({ kind: 'missing-consumer', consumer: consumer.name, query: query.name });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return failures;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function collectUniqueLinkTypes(plugins: readonly Plugin[]): LinkTypeDeclaration[] {
|
|
435
|
+
const byName = new Map<string, LinkTypeDeclaration>();
|
|
436
|
+
|
|
437
|
+
for (const linkType of plugins.flatMap((plugin) => plugin.linkTypes)) {
|
|
438
|
+
if (!byName.has(linkType.name)) {
|
|
439
|
+
byName.set(linkType.name, linkType);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return [...byName.values()];
|
|
444
|
+
}
|