@pepys/kit 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.
Files changed (68) hide show
  1. package/README.md +63 -2
  2. package/package.json +34 -4
  3. package/src/constants.ts +1 -0
  4. package/src/consumers/define-consumer.ts +20 -0
  5. package/src/consumers/types.ts +48 -0
  6. package/src/delivery-checks/build-delivery-proof.ts +12 -0
  7. package/src/delivery-checks/is-delivery-authentic.ts +49 -0
  8. package/src/deployments/check-deployment.ts +445 -0
  9. package/src/deployments/define-deployment.ts +7 -0
  10. package/src/deployments/define-plugin.ts +56 -0
  11. package/src/deployments/list-receive-routes.ts +27 -0
  12. package/src/deployments/types.ts +112 -0
  13. package/src/entity-ids/build-entity-id.ts +16 -0
  14. package/src/entity-ids/entity-id-failure.ts +6 -0
  15. package/src/entity-ids/entity-id-parts.ts +5 -0
  16. package/src/entity-ids/fact-kinds.ts +3 -0
  17. package/src/entity-ids/find-entity-id-failure.ts +25 -0
  18. package/src/entity-ids/parse-entity-id.ts +27 -0
  19. package/src/fact-envelope-cases.json +464 -0
  20. package/src/facts/build-enrichment-fact-id.ts +15 -0
  21. package/src/facts/build-event-fact-id.ts +11 -0
  22. package/src/facts/build-fact-hash.ts +6 -0
  23. package/src/facts/build-manual-fact-id.ts +7 -0
  24. package/src/facts/build-observation-fact-id.ts +15 -0
  25. package/src/facts/build-webhook-fact-id.ts +11 -0
  26. package/src/facts/parse-fact-envelope.ts +44 -0
  27. package/src/facts/parse-stream-name.ts +33 -0
  28. package/src/generators/build-payload-shape.ts +80 -0
  29. package/src/generators/collect-view-kinds.ts +8 -0
  30. package/src/generators/format-column-type.ts +16 -0
  31. package/src/generators/format-entity-ids.ts +46 -0
  32. package/src/generators/format-fact-reader.ts +194 -0
  33. package/src/generators/format-literal.ts +13 -0
  34. package/src/generators/format-model-contract.ts +23 -0
  35. package/src/generators/format-operations.ts +109 -0
  36. package/src/generators/format-payload-shape.ts +181 -0
  37. package/src/generators/format-property-access.ts +6 -0
  38. package/src/generators/format-property-name.ts +6 -0
  39. package/src/generators/format-row-readers.ts +116 -0
  40. package/src/generators/generate-boundary-glue.ts +60 -0
  41. package/src/generators/generate-sqlite-schema.ts +78 -0
  42. package/src/generators/is-identifier.ts +5 -0
  43. package/src/generators/quote-sql-identifier.ts +3 -0
  44. package/src/generators/to-entity-id-type-name.ts +6 -0
  45. package/src/generators/to-pascal-case.ts +7 -0
  46. package/src/generators.ts +4 -0
  47. package/src/ids/types.ts +25 -0
  48. package/src/index.ts +37 -0
  49. package/src/link-types/define-link-type.ts +9 -0
  50. package/src/link-types/types.ts +15 -0
  51. package/src/links/build-link-registry.ts +7 -0
  52. package/src/links/find-link-problem.ts +53 -0
  53. package/src/links/link-check-cases.json +274 -0
  54. package/src/links/types.ts +27 -0
  55. package/src/queries/define-query.ts +14 -0
  56. package/src/queries/types.ts +61 -0
  57. package/src/send/create-fact-batch-sender.ts +143 -0
  58. package/src/send/types.ts +25 -0
  59. package/src/send.ts +2 -0
  60. package/src/sources/define-fact-family.ts +10 -0
  61. package/src/sources/define-source.ts +7 -0
  62. package/src/sources/read-fact-batch.ts +145 -0
  63. package/src/sources/receive-fact-batch.ts +11 -0
  64. package/src/sources/types.ts +170 -0
  65. package/src/store-reader/store-reader.ts +76 -0
  66. package/src/tables/define-table.ts +13 -0
  67. package/src/tables/table-row.ts +20 -0
  68. package/src/types.ts +47 -0
@@ -0,0 +1,145 @@
1
+ import { isRecord, 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
+ Sender,
13
+ SourceContext,
14
+ } from './types.ts';
15
+
16
+ const batchFactSchema = z.strictObject({
17
+ type: z.string().min(1),
18
+ event: z.string().min(1),
19
+ entity: z.string(),
20
+ actor: z.string().optional(),
21
+ at: z.string(),
22
+ payload: z.unknown(),
23
+ });
24
+
25
+ const factBatchBodySchema = z.strictObject({
26
+ v: z.literal(1),
27
+ facts: z.array(batchFactSchema),
28
+ }) satisfies z.ZodType<FactBatchBody>;
29
+
30
+ // The read of a fact batch path. The testing package builds its envelopes with it, as ingest does.
31
+ export function readFactBatch(delivery: Delivery, source: SourceContext): Received {
32
+ const json = parseJSON(new TextDecoder().decode(delivery.body));
33
+
34
+ if (!json.ok) {
35
+ return { kind: 'refused', status: 400, reason: 'the body is not JSON' };
36
+ }
37
+
38
+ const body = factBatchBodySchema.safeParse(json.value);
39
+
40
+ if (!body.success) {
41
+ return { kind: 'refused', status: 422, reason: z.prettifyError(body.error) };
42
+ }
43
+
44
+ if (body.data.facts.length === 0) {
45
+ return { kind: 'ignored' };
46
+ }
47
+
48
+ const facts: PendingFact[] = [];
49
+
50
+ for (const [index, fact] of body.data.facts.entries()) {
51
+ const built = buildPendingFact(fact, source, delivery.sender);
52
+
53
+ if (!built.ok) {
54
+ return { kind: 'refused', status: 422, reason: `fact ${index}: ${built.failure}` };
55
+ }
56
+
57
+ facts.push(built.value);
58
+ }
59
+
60
+ return { kind: 'batch', batch: { facts } };
61
+ }
62
+
63
+ function parseJSON(text: string): Result<unknown, null> {
64
+ try {
65
+ const value: unknown = JSON.parse(text);
66
+
67
+ return { ok: true, value };
68
+ } catch {
69
+ return { ok: false, failure: null };
70
+ }
71
+ }
72
+
73
+ type BatchFact = FactBatchBody['facts'][number];
74
+
75
+ function buildPendingFact(
76
+ fact: BatchFact,
77
+ source: SourceContext,
78
+ sender: Sender | undefined,
79
+ ): Result<PendingFact, string> {
80
+ const family = findFamily(source.families, fact.type);
81
+
82
+ if (family === undefined) {
83
+ return { ok: false, failure: `the source has no fact type ${fact.type}` };
84
+ }
85
+
86
+ if (!fact.entity.startsWith(`${family.entityKind}:`)) {
87
+ return { ok: false, failure: `the entity is not of the kind ${family.entityKind}` };
88
+ }
89
+
90
+ const sent = buildSentPayload(fact.payload, sender);
91
+
92
+ if (!sent.ok) {
93
+ return sent;
94
+ }
95
+
96
+ const payload = family.types[fact.type]?.safeParse(sent.value);
97
+
98
+ if (payload?.success !== true) {
99
+ return { ok: false, failure: `the payload does not fit the type ${fact.type}` };
100
+ }
101
+
102
+ return {
103
+ ok: true,
104
+ value: {
105
+ stream: toTagged<StreamName>(family.stream),
106
+ fact: {
107
+ id: buildEventFactID({ source: source.name, event: fact.event }),
108
+ type: fact.type,
109
+ v: 1,
110
+ api: family.api,
111
+ entity: toTagged<EntityID>(fact.entity),
112
+ ...(fact.actor === undefined ? {} : { actor: toTagged<EntityID>(fact.actor) }),
113
+ at: fact.at,
114
+
115
+ // the payload as sent: a schema may strip or reshape, and the log keeps what arrived
116
+ payload: sent.value,
117
+ },
118
+ blobs: [],
119
+ },
120
+ };
121
+ }
122
+
123
+ function findFamily(families: readonly FactFamily[], type: string): FactFamily | undefined {
124
+ return families.find((family) => Object.hasOwn(family.types, type));
125
+ }
126
+
127
+ // On a path with a proven sender, the stored `origin` is that sender, whatever the body holds: the
128
+ // route's secret proves the batch, and only the proof says which sender wrote it.
129
+ function buildSentPayload(payload: unknown, sender: Sender | undefined): Result<unknown, string> {
130
+ if (sender === undefined) {
131
+ return { ok: true, value: payload };
132
+ }
133
+
134
+ if (!isRecord(payload)) {
135
+ return {
136
+ ok: false,
137
+ failure: 'the payload is not an object, so it cannot hold the proven sender',
138
+ };
139
+ }
140
+
141
+ return {
142
+ ok: true,
143
+ value: { ...payload, origin: { issuer: sender.issuer, subject: sender.subject } },
144
+ };
145
+ }
@@ -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
+ }
@@ -0,0 +1,170 @@
1
+ import type { z } from 'zod';
2
+ import type { BlobHash, EntityID, StreamName } from '../ids/types.ts';
3
+ import type { EntityKind } from '../link-types/types.ts';
4
+ import type { DeclaredStreamName, FactEnvelope } from '../types.ts';
5
+
6
+ export interface FactFamily<
7
+ Types extends Readonly<Record<string, z.ZodType>> = Readonly<Record<string, z.ZodType>>,
8
+ Kind extends EntityKind = EntityKind,
9
+ > {
10
+ readonly stream: DeclaredStreamName;
11
+ readonly api: string;
12
+
13
+ // the kind of the `entity` of every fact of the family
14
+ readonly entityKind: Kind;
15
+
16
+ // one zod schema per fact type; a fact type belongs to one family in the deployment
17
+ readonly types: Types;
18
+ }
19
+
20
+ // A source holds no secret and reads no environment.
21
+ export interface SourceDefinition<Family extends FactFamily = FactFamily> {
22
+ readonly name: string;
23
+ readonly families: readonly Family[];
24
+ readonly live?: ReceivePath | PullPath | undefined;
25
+ readonly recovery?: PullPath | undefined;
26
+ readonly backfill?: PullPath | undefined;
27
+ readonly resync?: PullPath | undefined;
28
+ }
29
+
30
+ // Ingest owns every step of a delivery but `read`: the method, the size, the check, the sender, the
31
+ // decoding, the fact limit, the appends and the answer.
32
+ export interface ReceivePath {
33
+ readonly kind: 'receive';
34
+ readonly route: string;
35
+
36
+ // More routes that pass the same check and sender, answer as an ignored delivery, and store
37
+ // nothing. An OTLP exporter sends metrics and traces beside its logs, and retries a refusal.
38
+ readonly ignoredRoutes?: readonly string[] | undefined;
39
+ readonly check: DeliveryCheck;
40
+
41
+ // Default 1 MiB, for the body as sent and for the body after decoding.
42
+ readonly bodyByteLimit?: number | undefined;
43
+
44
+ // Default 100 facts in one delivery.
45
+ readonly factLimit?: number | undefined;
46
+
47
+ // The `content-encoding` values the path takes besides none. Default none.
48
+ readonly encodings?: readonly 'gzip'[] | undefined;
49
+
50
+ // 'status' (default): 200 {"status":"stored","stored":<n>}, or 202 {"status":"ignored"}.
51
+ // 'empty': 200 {} for stored and for ignored, which an OTLP exporter expects.
52
+ readonly answer?: 'status' | 'empty' | undefined;
53
+
54
+ // A mark for the deployment's network rules. Default 'private'. It changes no answer.
55
+ readonly reach?: 'public' | 'private' | undefined;
56
+
57
+ // The path needs a proven sender: ingest answers 401 and stores nothing without one.
58
+ readonly sender?: SenderRequirement | undefined;
59
+
60
+ // Pure: it writes nothing.
61
+ readonly read: (delivery: Delivery, source: SourceContext) => Received;
62
+ }
63
+
64
+ // `hmac-sha256`: the header holds `sha256=<hex of HMAC-SHA256(secret, body as sent)>`. `token`: it
65
+ // holds the secret, or `Bearer <secret>`. Neither signs a time, so a fact ID must come from the
66
+ // signed body, and a replay is then a copy that every consumer drops.
67
+ export interface DeliveryCheck {
68
+ readonly kind: 'hmac-sha256' | 'token';
69
+ readonly header: string;
70
+ readonly secretEnv: string;
71
+ }
72
+
73
+ // The proof a path needs, as data. `kind` picks the adapter of the `SenderProof` port. The audience
74
+ // and the folder of trust documents belong to the deployment, so a plug-in names only the
75
+ // variables that hold them.
76
+ export interface SenderRequirement {
77
+ readonly kind: 'imp-identity';
78
+ readonly audienceEnv: string;
79
+ readonly issuersEnv: string;
80
+ }
81
+
82
+ export interface Delivery {
83
+ headers: Headers;
84
+
85
+ // after the check passed, and after decoding
86
+ body: Uint8Array;
87
+
88
+ // Present on every call for a path with a `sender`. `read` never takes a sender from `headers`.
89
+ sender?: Sender | undefined;
90
+ }
91
+
92
+ export interface Sender {
93
+ readonly issuer: string;
94
+ readonly subject: string;
95
+ readonly name: string;
96
+ }
97
+
98
+ // The port ingest calls for a path with a `sender`. It answers null when nothing proves a sender.
99
+ export interface SenderProof {
100
+ prove: (requirement: SenderRequirement, headers: Headers) => Promise<Sender | null>;
101
+ }
102
+
103
+ export interface SourceContext {
104
+ name: string;
105
+ families: readonly FactFamily[];
106
+ }
107
+
108
+ // Ingest answers 401, 405, 413 and 415 itself, so `read` refuses with 400 or 422 only.
109
+ export type Received =
110
+ | { kind: 'refused'; status: 400 | 422; reason: string }
111
+ | { kind: 'ignored' }
112
+ | { kind: 'batch'; batch: Batch };
113
+
114
+ // Ingest reads the variables in `env` at start and passes their values.
115
+ export interface PullPath {
116
+ readonly kind: 'pull';
117
+ readonly name: string;
118
+ readonly intervalMs: number | null;
119
+ readonly env?: readonly string[] | undefined;
120
+ readonly pull: (cursor: string | null, context: PullContext) => Promise<Pulled>;
121
+ }
122
+
123
+ export interface PullContext {
124
+ env: Readonly<Record<string, string>>;
125
+
126
+ // For a `resync` pull, the source's marked entities when the pull starts; empty for any other
127
+ // pull. Ingest removes each of these marks once the batch the pull returns is stored, so a pull
128
+ // that cannot read every marked entity answers `unavailable` and every mark stays.
129
+ marks: readonly ResyncMark[];
130
+ }
131
+
132
+ // An entity whose row may be behind the source, with the locator of the last fact ingest stored
133
+ // for it, if that fact had one.
134
+ export interface ResyncMark {
135
+ entity: EntityID;
136
+ locator: string | undefined;
137
+ }
138
+
139
+ export type Pulled =
140
+ | { kind: 'batch'; batch: Batch; cursor: string; isDone: boolean }
141
+ | { kind: 'expired' }
142
+ | { kind: 'unavailable'; retryAfterMs: number }
143
+ | { kind: 'rejected'; reason: string };
144
+
145
+ export interface Batch {
146
+ facts: readonly PendingFact[];
147
+ }
148
+
149
+ // The body of a signed fact batch. Ingest builds each fact ID as `event:<source>:<event>`.
150
+ export interface FactBatchBody {
151
+ v: 1;
152
+ facts: readonly {
153
+ type: string;
154
+ event: string;
155
+ entity: string;
156
+ actor?: string | undefined;
157
+ at: string;
158
+ payload: unknown;
159
+ }[];
160
+ }
161
+
162
+ export interface PendingFact {
163
+ stream: StreamName;
164
+ fact: FactEnvelope<unknown>;
165
+ blobs: readonly { hash: BlobHash; bytes: Uint8Array }[];
166
+
167
+ // What a source's resync pull needs to read the entity again, such as an API path. Ingest keeps
168
+ // it beside the entity's mark, and stores it in no fact.
169
+ locator?: string | undefined;
170
+ }
@@ -0,0 +1,76 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { ConsumerDeclaration } from '../consumers/types.ts';
3
+ import type { StoreTables } from '../queries/types.ts';
4
+ import type { Result } from '../types.ts';
5
+
6
+ // One row of the `link` table that the host keeps in each consumer file.
7
+ export interface LinkRow {
8
+ type: string;
9
+ from_id: string;
10
+ to_id: string;
11
+ scope: string | null;
12
+ origin: string;
13
+ confidence: number;
14
+ evidence: string;
15
+ }
16
+
17
+ // The two views of every connection. They cover the attached consumers only.
18
+ export interface SharedViews {
19
+ // one row per entity of every entity view of every attached consumer
20
+ entity: {
21
+ id: string;
22
+ kind: string;
23
+ title: string;
24
+ url: string | null;
25
+ actor: string | null;
26
+ updated_at: string;
27
+ };
28
+
29
+ // every link of every attached consumer
30
+ link_effective: LinkRow;
31
+ }
32
+
33
+ // The host's own tables of a consumer file, reached as `<consumer>.<table>`.
34
+ export type HostTables<Consumers extends readonly ConsumerDeclaration[]> = {
35
+ [Name in Consumers[number]['name'] as `${Name}.offsets`]: {
36
+ stream: string;
37
+ last_seq_num: number;
38
+ };
39
+ };
40
+
41
+ export type ConsumersDB<Consumers extends readonly ConsumerDeclaration[]> = StoreTables<Consumers> &
42
+ HostTables<Consumers> &
43
+ SharedViews;
44
+
45
+ export interface AttachedCall {
46
+ // the version folder each attached consumer was read from, fixed for the whole call
47
+ attached: Readonly<Record<string, { version: string }>>;
48
+
49
+ // the `whenCurrent` consumers that had no current version
50
+ skipped: readonly string[];
51
+ }
52
+
53
+ // The work did not run: these consumers have no current version.
54
+ export interface StoreReaderFailure {
55
+ reason: 'no-current-version';
56
+ consumers: readonly string[];
57
+ }
58
+
59
+ export interface WithConsumersOptions {
60
+ // More consumers to attach, each only when it has a current version. `db` has no typed table for
61
+ // them, and their rows show in the two shared views.
62
+ whenCurrent?: readonly ConsumerDeclaration[] | undefined;
63
+ }
64
+
65
+ // The port a query reads through. Adapters: Turso in the API, one in-memory database in the
66
+ // testing package.
67
+ export interface StoreReader {
68
+ // One read-only connection with the current file of each of `consumers` attached, and `work` in
69
+ // one transaction on it.
70
+ withConsumers: <const Consumers extends readonly ConsumerDeclaration[], Value>(
71
+ consumers: Consumers,
72
+ work: (db: Kysely<ConsumersDB<Consumers>>, call: AttachedCall) => Promise<Value>,
73
+ options?: WithConsumersOptions,
74
+ ) => Promise<Result<Value, StoreReaderFailure>>;
75
+ close: () => Promise<void>;
76
+ }
@@ -0,0 +1,13 @@
1
+ import type { ColumnDeclaration, IndexDeclaration, TableDeclaration } from '../types.ts';
2
+
3
+ interface TableInput<Columns extends Readonly<Record<string, ColumnDeclaration>>> {
4
+ readonly columns: Columns;
5
+ readonly primaryKey: readonly NoInfer<keyof Columns & string>[];
6
+ readonly indexes: readonly IndexDeclaration<NoInfer<keyof Columns & string>>[];
7
+ }
8
+
9
+ export function defineTable<const Columns extends Readonly<Record<string, ColumnDeclaration>>>(
10
+ table: TableInput<Columns>,
11
+ ): TableDeclaration<Columns> {
12
+ return table;
13
+ }
@@ -0,0 +1,20 @@
1
+ import type { EntityID } from '../ids/types.ts';
2
+ import type { ColumnDeclaration, TableDeclaration } from '../types.ts';
3
+
4
+ interface KindTypes {
5
+ text: string;
6
+ integer: number;
7
+ real: number;
8
+ boolean: boolean;
9
+ json: unknown;
10
+ entityID: EntityID;
11
+ timestamp: string;
12
+ }
13
+
14
+ type ColumnValue<Column extends ColumnDeclaration> = Column['nullable'] extends true
15
+ ? KindTypes[Column['kind']] | null
16
+ : KindTypes[Column['kind']];
17
+
18
+ export type TableRow<Table extends TableDeclaration> = {
19
+ -readonly [Name in keyof Table['columns']]: ColumnValue<Table['columns'][Name]>;
20
+ };
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { EntityID, FactID } from './ids/types.ts';
2
+
3
+ export type Result<Value, Failure> = { ok: true; value: Value } | { ok: false; failure: Failure };
4
+
5
+ export interface FactEnvelope<Payload> {
6
+ id: FactID;
7
+ type: string;
8
+ v: 1;
9
+ api: string;
10
+ entity: EntityID;
11
+ actor?: EntityID | undefined;
12
+ at: string;
13
+ cursor?: string | undefined;
14
+ payload: Payload;
15
+ }
16
+
17
+ // The name of a stream as a declaration writes it. A plain literal fits it, where the branded
18
+ // `StreamName` needs a parse.
19
+ export type DeclaredStreamName = `facts.${string}.${string}`;
20
+
21
+ export type ColumnKind =
22
+ | 'text'
23
+ | 'integer'
24
+ | 'real'
25
+ | 'boolean'
26
+ | 'json'
27
+ | 'entityID'
28
+ | 'timestamp';
29
+
30
+ export interface ColumnDeclaration {
31
+ readonly kind: ColumnKind;
32
+ readonly nullable?: boolean;
33
+ }
34
+
35
+ export interface IndexDeclaration<ColumnName extends string = string> {
36
+ readonly columns: readonly ColumnName[];
37
+ }
38
+
39
+ export interface TableDeclaration<
40
+ Columns extends Readonly<Record<string, ColumnDeclaration>> = Readonly<
41
+ Record<string, ColumnDeclaration>
42
+ >,
43
+ > {
44
+ readonly columns: Columns;
45
+ readonly primaryKey: readonly string[];
46
+ readonly indexes: readonly IndexDeclaration[];
47
+ }