@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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
{
|
|
2
|
+
"consumer": {
|
|
3
|
+
"name": "archive_shelf",
|
|
4
|
+
"links": [
|
|
5
|
+
{
|
|
6
|
+
"name": "holds",
|
|
7
|
+
"from": ["archive:shelf"],
|
|
8
|
+
"to": ["archive:box", "archive:crate"],
|
|
9
|
+
"hub": false
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"name": "stands_in",
|
|
13
|
+
"from": ["archive:shelf"],
|
|
14
|
+
"to": ["archive:room"],
|
|
15
|
+
"hub": true
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"cases": [
|
|
20
|
+
{
|
|
21
|
+
"title": "accepts a declared link with both ends of an allowed kind",
|
|
22
|
+
"link": {
|
|
23
|
+
"type": "holds",
|
|
24
|
+
"from": "archive:shelf:s-1",
|
|
25
|
+
"to": "archive:box:b-1",
|
|
26
|
+
"confidence": 1,
|
|
27
|
+
"evidence": "event:archive:e-1"
|
|
28
|
+
},
|
|
29
|
+
"problem": null
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"title": "accepts an end of the second allowed kind",
|
|
33
|
+
"link": {
|
|
34
|
+
"type": "holds",
|
|
35
|
+
"from": "archive:shelf:s-1",
|
|
36
|
+
"to": "archive:crate:c-9",
|
|
37
|
+
"confidence": 1,
|
|
38
|
+
"evidence": "event:archive:e-1"
|
|
39
|
+
},
|
|
40
|
+
"problem": null
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"title": "accepts a link to an entity whose row has not arrived",
|
|
44
|
+
"link": {
|
|
45
|
+
"type": "stands_in",
|
|
46
|
+
"from": "archive:shelf:s-1",
|
|
47
|
+
"to": "archive:room:never-filed",
|
|
48
|
+
"confidence": 1,
|
|
49
|
+
"evidence": "event:archive:e-1"
|
|
50
|
+
},
|
|
51
|
+
"problem": null
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "accepts a confidence of 0",
|
|
55
|
+
"link": {
|
|
56
|
+
"type": "holds",
|
|
57
|
+
"from": "archive:shelf:s-1",
|
|
58
|
+
"to": "archive:box:b-1",
|
|
59
|
+
"confidence": 0,
|
|
60
|
+
"evidence": "event:archive:e-1"
|
|
61
|
+
},
|
|
62
|
+
"problem": null
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"title": "accepts a confidence of 1",
|
|
66
|
+
"link": {
|
|
67
|
+
"type": "holds",
|
|
68
|
+
"from": "archive:shelf:s-1",
|
|
69
|
+
"to": "archive:box:b-1",
|
|
70
|
+
"confidence": 1,
|
|
71
|
+
"evidence": "event:archive:e-1"
|
|
72
|
+
},
|
|
73
|
+
"problem": null
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"title": "accepts an evidence that is not a fact ID",
|
|
77
|
+
"link": {
|
|
78
|
+
"type": "holds",
|
|
79
|
+
"from": "archive:shelf:s-1",
|
|
80
|
+
"to": "archive:box:b-1",
|
|
81
|
+
"confidence": 1,
|
|
82
|
+
"evidence": "x"
|
|
83
|
+
},
|
|
84
|
+
"problem": null
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"title": "refuses a type the consumer does not declare",
|
|
88
|
+
"link": {
|
|
89
|
+
"type": "leans_on",
|
|
90
|
+
"from": "archive:shelf:s-1",
|
|
91
|
+
"to": "archive:box:b-1",
|
|
92
|
+
"confidence": 1,
|
|
93
|
+
"evidence": "event:archive:e-1"
|
|
94
|
+
},
|
|
95
|
+
"problem": "not-declared"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"title": "refuses a from end that is not an entity ID",
|
|
99
|
+
"link": {
|
|
100
|
+
"type": "holds",
|
|
101
|
+
"from": "archive",
|
|
102
|
+
"to": "archive:box:b-1",
|
|
103
|
+
"confidence": 1,
|
|
104
|
+
"evidence": "event:archive:e-1"
|
|
105
|
+
},
|
|
106
|
+
"problem": "not-an-entity-id"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"title": "refuses a to end with no ID part",
|
|
110
|
+
"link": {
|
|
111
|
+
"type": "holds",
|
|
112
|
+
"from": "archive:shelf:s-1",
|
|
113
|
+
"to": "archive:box",
|
|
114
|
+
"confidence": 1,
|
|
115
|
+
"evidence": "event:archive:e-1"
|
|
116
|
+
},
|
|
117
|
+
"problem": "not-an-entity-id"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"title": "refuses a to end with an empty ID",
|
|
121
|
+
"link": {
|
|
122
|
+
"type": "holds",
|
|
123
|
+
"from": "archive:shelf:s-1",
|
|
124
|
+
"to": "archive:box:",
|
|
125
|
+
"confidence": 1,
|
|
126
|
+
"evidence": "event:archive:e-1"
|
|
127
|
+
},
|
|
128
|
+
"problem": "not-an-entity-id"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"title": "refuses a from end whose source is not lower snake case",
|
|
132
|
+
"link": {
|
|
133
|
+
"type": "holds",
|
|
134
|
+
"from": "Archive:shelf:s-1",
|
|
135
|
+
"to": "archive:box:b-1",
|
|
136
|
+
"confidence": 1,
|
|
137
|
+
"evidence": "event:archive:e-1"
|
|
138
|
+
},
|
|
139
|
+
"problem": "not-an-entity-id"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"title": "refuses a fact ID as an end",
|
|
143
|
+
"link": {
|
|
144
|
+
"type": "holds",
|
|
145
|
+
"from": "archive:shelf:s-1",
|
|
146
|
+
"to": "event:archive:e-1",
|
|
147
|
+
"confidence": 1,
|
|
148
|
+
"evidence": "event:archive:e-1"
|
|
149
|
+
},
|
|
150
|
+
"problem": "not-an-entity-id"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"title": "refuses a from end of a kind the type does not allow",
|
|
154
|
+
"link": {
|
|
155
|
+
"type": "holds",
|
|
156
|
+
"from": "archive:box:b-2",
|
|
157
|
+
"to": "archive:box:b-1",
|
|
158
|
+
"confidence": 1,
|
|
159
|
+
"evidence": "event:archive:e-1"
|
|
160
|
+
},
|
|
161
|
+
"problem": "wrong-kind"
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"title": "refuses a to end of a kind the type does not allow",
|
|
165
|
+
"link": {
|
|
166
|
+
"type": "holds",
|
|
167
|
+
"from": "archive:shelf:s-1",
|
|
168
|
+
"to": "archive:room:r-1",
|
|
169
|
+
"confidence": 1,
|
|
170
|
+
"evidence": "event:archive:e-1"
|
|
171
|
+
},
|
|
172
|
+
"problem": "wrong-kind"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
"title": "refuses a kind that only starts with an allowed kind",
|
|
176
|
+
"link": {
|
|
177
|
+
"type": "holds",
|
|
178
|
+
"from": "archive:shelf:s-1",
|
|
179
|
+
"to": "archive:box_lid:l-1",
|
|
180
|
+
"confidence": 1,
|
|
181
|
+
"evidence": "event:archive:e-1"
|
|
182
|
+
},
|
|
183
|
+
"problem": "wrong-kind"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
"title": "refuses an allowed kind of another source",
|
|
187
|
+
"link": {
|
|
188
|
+
"type": "holds",
|
|
189
|
+
"from": "archive:shelf:s-1",
|
|
190
|
+
"to": "depot:box:b-1",
|
|
191
|
+
"confidence": 1,
|
|
192
|
+
"evidence": "event:archive:e-1"
|
|
193
|
+
},
|
|
194
|
+
"problem": "wrong-kind"
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"title": "refuses a confidence below 0",
|
|
198
|
+
"link": {
|
|
199
|
+
"type": "holds",
|
|
200
|
+
"from": "archive:shelf:s-1",
|
|
201
|
+
"to": "archive:box:b-1",
|
|
202
|
+
"confidence": -0.1,
|
|
203
|
+
"evidence": "event:archive:e-1"
|
|
204
|
+
},
|
|
205
|
+
"problem": "bad-confidence"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
"title": "refuses a confidence above 1",
|
|
209
|
+
"link": {
|
|
210
|
+
"type": "holds",
|
|
211
|
+
"from": "archive:shelf:s-1",
|
|
212
|
+
"to": "archive:box:b-1",
|
|
213
|
+
"confidence": 1.5,
|
|
214
|
+
"evidence": "event:archive:e-1"
|
|
215
|
+
},
|
|
216
|
+
"problem": "bad-confidence"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"title": "refuses an empty evidence",
|
|
220
|
+
"link": {
|
|
221
|
+
"type": "holds",
|
|
222
|
+
"from": "archive:shelf:s-1",
|
|
223
|
+
"to": "archive:box:b-1",
|
|
224
|
+
"confidence": 1,
|
|
225
|
+
"evidence": ""
|
|
226
|
+
},
|
|
227
|
+
"problem": "no-evidence"
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"title": "reports the type before a bad end",
|
|
231
|
+
"link": {
|
|
232
|
+
"type": "leans_on",
|
|
233
|
+
"from": "archive",
|
|
234
|
+
"to": "archive:box:b-1",
|
|
235
|
+
"confidence": 1,
|
|
236
|
+
"evidence": "event:archive:e-1"
|
|
237
|
+
},
|
|
238
|
+
"problem": "not-declared"
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
"title": "reports the from end before the to end",
|
|
242
|
+
"link": {
|
|
243
|
+
"type": "holds",
|
|
244
|
+
"from": "archive",
|
|
245
|
+
"to": "archive:room:r-1",
|
|
246
|
+
"confidence": 1,
|
|
247
|
+
"evidence": "event:archive:e-1"
|
|
248
|
+
},
|
|
249
|
+
"problem": "not-an-entity-id"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
"title": "reports a wrong kind before a bad confidence",
|
|
253
|
+
"link": {
|
|
254
|
+
"type": "holds",
|
|
255
|
+
"from": "archive:shelf:s-1",
|
|
256
|
+
"to": "archive:room:r-1",
|
|
257
|
+
"confidence": 2,
|
|
258
|
+
"evidence": "event:archive:e-1"
|
|
259
|
+
},
|
|
260
|
+
"problem": "wrong-kind"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"title": "reports a bad confidence before an empty evidence",
|
|
264
|
+
"link": {
|
|
265
|
+
"type": "holds",
|
|
266
|
+
"from": "archive:shelf:s-1",
|
|
267
|
+
"to": "archive:box:b-1",
|
|
268
|
+
"confidence": 2,
|
|
269
|
+
"evidence": ""
|
|
270
|
+
},
|
|
271
|
+
"problem": "bad-confidence"
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// What is wrong with one link that a consumer returns. The host has no deployment registry, so a
|
|
2
|
+
// type is declared by the consumer that writes it, or by nothing.
|
|
3
|
+
export type LinkProblem =
|
|
4
|
+
| { kind: 'not-declared'; type: string; consumer: string }
|
|
5
|
+
| { kind: 'not-an-entity-id'; end: 'from' | 'to'; id: string }
|
|
6
|
+
| { kind: 'wrong-kind'; end: 'from' | 'to'; id: string; allowed: readonly string[] }
|
|
7
|
+
| { kind: 'bad-confidence'; confidence: number }
|
|
8
|
+
| { kind: 'no-evidence' };
|
|
9
|
+
|
|
10
|
+
// A consumer as its declaration or its manifest holds it, so the shared case file fits it too.
|
|
11
|
+
export interface LinkCheckConsumer {
|
|
12
|
+
name: string;
|
|
13
|
+
links: readonly { name: string; from: readonly string[]; to: readonly string[] }[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CheckedLink {
|
|
17
|
+
type: string;
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
confidence: number;
|
|
21
|
+
evidence: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { ConsumerDeclaration } from '../consumers/types.ts';
|
|
3
|
+
import type { QueryDefinition } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export function defineQuery<
|
|
6
|
+
const Name extends string,
|
|
7
|
+
InputSchema extends z.ZodType,
|
|
8
|
+
OutputSchema extends z.ZodType,
|
|
9
|
+
const Consumers extends readonly ConsumerDeclaration[],
|
|
10
|
+
>(
|
|
11
|
+
query: QueryDefinition<Name, InputSchema, OutputSchema, Consumers>,
|
|
12
|
+
): QueryDefinition<Name, InputSchema, OutputSchema, Consumers> {
|
|
13
|
+
return query;
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Kysely } from 'kysely';
|
|
2
|
+
import type { Simplify, UnionToIntersection } from 'type-fest';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import type { ConsumerDeclaration } from '../consumers/types.ts';
|
|
5
|
+
import type { ConsumersDB } from '../store-reader/store-reader.ts';
|
|
6
|
+
import type { TableRow } from '../tables/table-row.ts';
|
|
7
|
+
|
|
8
|
+
// The Kysely tables of a set of consumers, from their declarations.
|
|
9
|
+
export type StoreTables<Consumers extends readonly ConsumerDeclaration[]> = Simplify<
|
|
10
|
+
UnionToIntersection<
|
|
11
|
+
{
|
|
12
|
+
[Index in keyof Consumers]: {
|
|
13
|
+
-readonly [Table in keyof Consumers[Index]['tables']]: TableRow<
|
|
14
|
+
Consumers[Index]['tables'][Table]
|
|
15
|
+
>;
|
|
16
|
+
};
|
|
17
|
+
}[number]
|
|
18
|
+
>
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
export interface QueryCall<Input, Consumers extends readonly ConsumerDeclaration[]> {
|
|
22
|
+
input: Input;
|
|
23
|
+
|
|
24
|
+
// One read-only connection with the current file of each consumer in `reads` attached, and the
|
|
25
|
+
// `entity` and `link_effective` views over those files.
|
|
26
|
+
db: Kysely<ConsumersDB<Consumers>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Generic over the schemas, not their types: `z.ZodType<Input>` keeps the parsed type alone, and a
|
|
30
|
+
// client needs the type a schema takes in.
|
|
31
|
+
export interface QueryDefinition<
|
|
32
|
+
Name extends string = string,
|
|
33
|
+
InputSchema extends z.ZodType = z.ZodType,
|
|
34
|
+
OutputSchema extends z.ZodType = z.ZodType,
|
|
35
|
+
Consumers extends readonly ConsumerDeclaration[] = readonly ConsumerDeclaration[],
|
|
36
|
+
> {
|
|
37
|
+
// snake case: the procedure key and the MCP tool name
|
|
38
|
+
readonly name: Name;
|
|
39
|
+
|
|
40
|
+
// the text an MCP agent reads
|
|
41
|
+
readonly description: string;
|
|
42
|
+
|
|
43
|
+
// plain zod objects with no transform, because the MCP schemas come from them
|
|
44
|
+
readonly input: InputSchema;
|
|
45
|
+
readonly output: OutputSchema;
|
|
46
|
+
readonly reads: Consumers;
|
|
47
|
+
readonly handler: (
|
|
48
|
+
call: QueryCall<z.output<InputSchema>, Consumers>,
|
|
49
|
+
) => Promise<z.input<OutputSchema>>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Any query, as a list of a plug-in's queries holds it. A handler takes its own call type, so no
|
|
53
|
+
// one call type fits every handler.
|
|
54
|
+
export type AnyQueryDefinition = Omit<QueryDefinition, 'handler'> & {
|
|
55
|
+
readonly handler: (call: never) => Promise<unknown>;
|
|
56
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { buildDeliveryProof } from '../delivery-checks/build-delivery-proof.ts';
|
|
3
|
+
import type { FactBatchBody, ReceivePath, SourceDefinition } from '../sources/types.ts';
|
|
4
|
+
import type { Result } from '../types.ts';
|
|
5
|
+
import type { OutgoingFact, SendFailure } from './types.ts';
|
|
6
|
+
|
|
7
|
+
interface FactBatchSenderOptions<Source extends SourceDefinition> {
|
|
8
|
+
source: Source;
|
|
9
|
+
url: string;
|
|
10
|
+
secret: string;
|
|
11
|
+
|
|
12
|
+
// Defaults to the global `fetch`. A route test passes ingest's own handler.
|
|
13
|
+
fetch?: ((request: Request) => Promise<Response>) | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type FactBatchSender<Source extends SourceDefinition> = (
|
|
17
|
+
facts: readonly OutgoingFact<Source>[],
|
|
18
|
+
) => Promise<Result<{ stored: number }, SendFailure>>;
|
|
19
|
+
|
|
20
|
+
const defaultFactLimit = 100;
|
|
21
|
+
|
|
22
|
+
// Signs each batch as the `check` of the source's receive path says, and sends it. A `retry` is a
|
|
23
|
+
// 502, 503 or 504 answer, a timeout or no connection: send the same batch again, unchanged. A
|
|
24
|
+
// `refused` is any other answer that is no success, 500 included: never resend that batch as it is.
|
|
25
|
+
export function createFactBatchSender<Source extends SourceDefinition>(
|
|
26
|
+
options: FactBatchSenderOptions<Source>,
|
|
27
|
+
): FactBatchSender<Source> {
|
|
28
|
+
const path = options.source.live;
|
|
29
|
+
|
|
30
|
+
if (path?.kind !== 'receive') {
|
|
31
|
+
throw new Error(`the source ${options.source.name} has no receive path to send to`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (options.secret === '') {
|
|
35
|
+
throw new Error(`the secret for the source ${options.source.name} is empty`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const send = options.fetch ?? fetch;
|
|
39
|
+
|
|
40
|
+
return async (facts) => {
|
|
41
|
+
const factLimit = path.factLimit ?? defaultFactLimit;
|
|
42
|
+
|
|
43
|
+
if (facts.length > factLimit) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
failure: {
|
|
47
|
+
kind: 'refused',
|
|
48
|
+
status: 413,
|
|
49
|
+
reason: `${facts.length} facts is over the path's limit of ${factLimit}`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const request = buildRequest(path, options.url, options.secret, facts);
|
|
55
|
+
|
|
56
|
+
const outcome = await sendRequest(send, request);
|
|
57
|
+
|
|
58
|
+
return outcome;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sendTimeoutMs = 30_000;
|
|
63
|
+
|
|
64
|
+
function buildRequest(
|
|
65
|
+
path: ReceivePath,
|
|
66
|
+
url: string,
|
|
67
|
+
secret: string,
|
|
68
|
+
facts: readonly FactBatchBody['facts'][number][],
|
|
69
|
+
): Request {
|
|
70
|
+
const batch: FactBatchBody = { v: 1, facts };
|
|
71
|
+
|
|
72
|
+
// the bytes that are signed are the bytes that are sent
|
|
73
|
+
const body = new TextEncoder().encode(JSON.stringify(batch));
|
|
74
|
+
|
|
75
|
+
return new Request(url, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'content-type': 'application/json',
|
|
79
|
+
[path.check.header]: buildDeliveryProof(path.check, secret, body),
|
|
80
|
+
},
|
|
81
|
+
body,
|
|
82
|
+
redirect: 'manual',
|
|
83
|
+
signal: AbortSignal.timeout(sendTimeoutMs),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function sendRequest(
|
|
88
|
+
send: (request: Request) => Promise<Response>,
|
|
89
|
+
request: Request,
|
|
90
|
+
): Promise<Result<{ stored: number }, SendFailure>> {
|
|
91
|
+
let answer: { status: number; text: string };
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const response = await send(request);
|
|
95
|
+
const text = await response.text();
|
|
96
|
+
|
|
97
|
+
answer = { status: response.status, text };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { ok: false, failure: { kind: 'retry', reason: String(error) } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return pickSendResult(answer.status, answer.text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const answerSchema = z.union([
|
|
106
|
+
z.object({ status: z.literal('stored'), stored: z.number().int().nonnegative() }),
|
|
107
|
+
z.object({ status: z.literal('ignored') }),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
// Ingest answers 500 for a batch that no resend of the same bytes can store, so only these mean
|
|
111
|
+
// that the same batch may succeed later.
|
|
112
|
+
const retryStatuses: ReadonlySet<number> = new Set([502, 503, 504]);
|
|
113
|
+
|
|
114
|
+
function pickSendResult(status: number, text: string): Result<{ stored: number }, SendFailure> {
|
|
115
|
+
if (retryStatuses.has(status)) {
|
|
116
|
+
return { ok: false, failure: { kind: 'retry', reason: `ingest answered ${status}: ${text}` } };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (status < 200 || status >= 300) {
|
|
120
|
+
return { ok: false, failure: { kind: 'refused', status, reason: text } };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const json = parseJSON(text);
|
|
124
|
+
const answer = answerSchema.safeParse(json);
|
|
125
|
+
|
|
126
|
+
// A success that names no count may still have stored the batch, and a repeat is safe, because
|
|
127
|
+
// every consumer drops a copy by its fact ID.
|
|
128
|
+
if (!answer.success) {
|
|
129
|
+
return { ok: false, failure: { kind: 'retry', reason: `ingest answered ${status}: ${text}` } };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { ok: true, value: { stored: answer.data.status === 'stored' ? answer.data.stored : 0 } };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseJSON(text: string): unknown {
|
|
136
|
+
try {
|
|
137
|
+
const value: unknown = JSON.parse(text);
|
|
138
|
+
|
|
139
|
+
return value;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { FactBatchBody, SourceDefinition } from '../sources/types.ts';
|
|
3
|
+
|
|
4
|
+
// One fact of a batch, typed by the families of the source: the type picks the payload schema, and
|
|
5
|
+
// the entity has the family's kind. It is a fact of the wire body whatever the source.
|
|
6
|
+
export type OutgoingFact<Source extends SourceDefinition> = FactBatchBody['facts'][number] &
|
|
7
|
+
{
|
|
8
|
+
[Family in Source['families'][number] as Family['stream']]: {
|
|
9
|
+
[Type in keyof Family['types'] & string]: {
|
|
10
|
+
type: Type;
|
|
11
|
+
event: string;
|
|
12
|
+
entity: `${Family['entityKind']}:${string}`;
|
|
13
|
+
actor?: string | undefined;
|
|
14
|
+
at: string;
|
|
15
|
+
payload: z.input<Family['types'][Type]>;
|
|
16
|
+
};
|
|
17
|
+
}[keyof Family['types'] & string];
|
|
18
|
+
}[Source['families'][number]['stream']];
|
|
19
|
+
|
|
20
|
+
// Every 5xx is a `retry`: ingest answers 500 when the log refuses an append for a fault of the
|
|
21
|
+
// deployment, and part of the batch may be stored by then. A sender that dropped the batch as
|
|
22
|
+
// refused would lose the rest.
|
|
23
|
+
export type SendFailure =
|
|
24
|
+
| { kind: 'refused'; status: number; reason: string }
|
|
25
|
+
| { kind: 'retry'; reason: string };
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { EntityKind } from '../link-types/types.ts';
|
|
3
|
+
import type { FactFamily } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export function defineFactFamily<
|
|
6
|
+
const Types extends Readonly<Record<string, z.ZodType>>,
|
|
7
|
+
const Kind extends EntityKind,
|
|
8
|
+
>(family: FactFamily<Types, Kind>): FactFamily<Types, Kind> {
|
|
9
|
+
return family;
|
|
10
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { toTagged } from '@zgeoff/std';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { buildEventFactID } from '../facts/build-event-fact-id.ts';
|
|
4
|
+
import type { EntityID, StreamName } from '../ids/types.ts';
|
|
5
|
+
import type { Result } from '../types.ts';
|
|
6
|
+
import type {
|
|
7
|
+
Delivery,
|
|
8
|
+
FactBatchBody,
|
|
9
|
+
FactFamily,
|
|
10
|
+
PendingFact,
|
|
11
|
+
Received,
|
|
12
|
+
SourceContext,
|
|
13
|
+
} from './types.ts';
|
|
14
|
+
|
|
15
|
+
const batchFactSchema = z.strictObject({
|
|
16
|
+
type: z.string().min(1),
|
|
17
|
+
event: z.string().min(1),
|
|
18
|
+
entity: z.string(),
|
|
19
|
+
actor: z.string().optional(),
|
|
20
|
+
at: z.string(),
|
|
21
|
+
payload: z.unknown(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const factBatchBodySchema = z.strictObject({
|
|
25
|
+
v: z.literal(1),
|
|
26
|
+
facts: z.array(batchFactSchema),
|
|
27
|
+
}) satisfies z.ZodType<FactBatchBody>;
|
|
28
|
+
|
|
29
|
+
// The read of a fact batch path. The testing package builds its envelopes with it, as ingest does.
|
|
30
|
+
export function readFactBatch(delivery: Delivery, source: SourceContext): Received {
|
|
31
|
+
const json = parseJSON(new TextDecoder().decode(delivery.body));
|
|
32
|
+
|
|
33
|
+
if (!json.ok) {
|
|
34
|
+
return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const body = factBatchBodySchema.safeParse(json.value);
|
|
38
|
+
|
|
39
|
+
if (!body.success) {
|
|
40
|
+
return { kind: 'refused', status: 422, reason: z.prettifyError(body.error) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (body.data.facts.length === 0) {
|
|
44
|
+
return { kind: 'ignored' };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const facts: PendingFact[] = [];
|
|
48
|
+
|
|
49
|
+
for (const [index, fact] of body.data.facts.entries()) {
|
|
50
|
+
const built = buildPendingFact(fact, source);
|
|
51
|
+
|
|
52
|
+
if (!built.ok) {
|
|
53
|
+
return { kind: 'refused', status: 422, reason: `fact ${index}: ${built.failure}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
facts.push(built.value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { kind: 'batch', batch: { facts } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseJSON(text: string): Result<unknown, null> {
|
|
63
|
+
try {
|
|
64
|
+
const value: unknown = JSON.parse(text);
|
|
65
|
+
|
|
66
|
+
return { ok: true, value };
|
|
67
|
+
} catch {
|
|
68
|
+
return { ok: false, failure: null };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type BatchFact = FactBatchBody['facts'][number];
|
|
73
|
+
|
|
74
|
+
function buildPendingFact(fact: BatchFact, source: SourceContext): Result<PendingFact, string> {
|
|
75
|
+
const family = findFamily(source.families, fact.type);
|
|
76
|
+
|
|
77
|
+
if (family === undefined) {
|
|
78
|
+
return { ok: false, failure: `the source has no fact type ${fact.type}` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!fact.entity.startsWith(`${family.entityKind}:`)) {
|
|
82
|
+
return { ok: false, failure: `the entity is not of the kind ${family.entityKind}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const payload = family.types[fact.type]?.safeParse(fact.payload);
|
|
86
|
+
|
|
87
|
+
if (payload?.success !== true) {
|
|
88
|
+
return { ok: false, failure: `the payload does not fit the type ${fact.type}` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: {
|
|
94
|
+
stream: toTagged<StreamName>(family.stream),
|
|
95
|
+
fact: {
|
|
96
|
+
id: buildEventFactID({ source: source.name, event: fact.event }),
|
|
97
|
+
type: fact.type,
|
|
98
|
+
v: 1,
|
|
99
|
+
api: family.api,
|
|
100
|
+
entity: toTagged<EntityID>(fact.entity),
|
|
101
|
+
...(fact.actor === undefined ? {} : { actor: toTagged<EntityID>(fact.actor) }),
|
|
102
|
+
at: fact.at,
|
|
103
|
+
|
|
104
|
+
// the payload as sent: a schema may strip or reshape, and the log keeps what arrived
|
|
105
|
+
payload: fact.payload,
|
|
106
|
+
},
|
|
107
|
+
blobs: [],
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findFamily(families: readonly FactFamily[], type: string): FactFamily | undefined {
|
|
113
|
+
return families.find((family) => Object.hasOwn(family.types, type));
|
|
114
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFactBatch } from './read-fact-batch.ts';
|
|
2
|
+
import type { ReceivePath } from './types.ts';
|
|
3
|
+
|
|
4
|
+
type FactBatchReceiver = Pick<
|
|
5
|
+
ReceivePath,
|
|
6
|
+
'route' | 'check' | 'bodyByteLimit' | 'factLimit' | 'reach' | 'sender'
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
export function receiveFactBatch(receiver: FactBatchReceiver): ReceivePath {
|
|
10
|
+
return { ...receiver, kind: 'receive', read: readFactBatch };
|
|
11
|
+
}
|