@pepys/kit 0.0.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pepys/kit",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "Declare a pepys plug-in or deployment: fact families, sources, consumers, tables, links and queries",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -271,6 +271,7 @@ const hostTableNames: ReadonlySet<string> = new Set([
271
271
  'change_log',
272
272
  'applied_facts',
273
273
  'scope_row',
274
+ 'scope_fact',
274
275
  ]);
275
276
 
276
277
  function collectReservedNames(
@@ -40,9 +40,11 @@ export interface Deployment<Plugins extends readonly Plugin[] = readonly Plugin[
40
40
  readonly settings?: Readonly<Record<string, Readonly<Record<string, SettingValue>>>> | undefined;
41
41
  }
42
42
 
43
- // The typed client of one plug-in's queries. A type only, so the kit needs no oRPC.
43
+ // The typed client of one plug-in's queries. A type only, so the kit needs no oRPC. A plug-in with
44
+ // no queries, written inside `defineDeployment`, takes its query type from the call's context, so
45
+ // a query name that is no literal gives no method rather than an index signature.
44
46
  export type PluginClient<Target extends Plugin> = {
45
- [Query in Target['queries'][number] as Query['name']]: (
47
+ [Query in Target['queries'][number] as string extends Query['name'] ? never : Query['name']]: (
46
48
  input: z.input<Query['input']>,
47
49
  ) => Promise<z.output<Query['output']>>;
48
50
  };
@@ -60,6 +60,11 @@ function buildPayloadField(name: string, schema: z.core.$ZodType): PayloadField
60
60
  shape: buildPayloadShape(optional.unwrap()),
61
61
  isOptional: true,
62
62
  }))
63
+ .with(P.instanceOf(z.ZodExactOptional), (optional) => ({
64
+ name,
65
+ shape: buildPayloadShape(optional.unwrap()),
66
+ isOptional: true,
67
+ }))
63
68
  .with(P.instanceOf(z.ZodDefault), (withDefault) => ({
64
69
  name,
65
70
  shape: buildPayloadShape(withDefault.unwrap()),
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { buildEnrichmentFactID } from './facts/build-enrichment-fact-id.ts';
2
+ export { buildLinkRegistry } from './links/build-link-registry.ts';
2
3
  export { buildEntityID } from './entity-ids/build-entity-id.ts';
3
4
  export { buildEventFactID } from './facts/build-event-fact-id.ts';
4
5
  export { buildManualFactID } from './facts/build-manual-fact-id.ts';
@@ -0,0 +1,7 @@
1
+ import type { LinkTypeDeclaration } from '../link-types/types.ts';
2
+ import type { LinkRegistry } from './types.ts';
3
+
4
+ // The deployment check refuses one name with two shapes, so a name maps to one declaration.
5
+ export function buildLinkRegistry(linkTypes: readonly LinkTypeDeclaration[]): LinkRegistry {
6
+ return new Map(linkTypes.map((linkType) => [linkType.name, linkType]));
7
+ }
@@ -1,3 +1,8 @@
1
+ import type { LinkTypeDeclaration } from '../link-types/types.ts';
2
+
3
+ // Each link type of a deployment, by name.
4
+ export type LinkRegistry = ReadonlyMap<string, LinkTypeDeclaration>;
5
+
1
6
  // What is wrong with one link that a consumer returns. The host has no deployment registry, so a
2
7
  // type is declared by the consumer that writes it, or by nothing.
3
8
  export type LinkProblem =
@@ -5,18 +5,23 @@ import type { ConsumerDeclaration } from '../consumers/types.ts';
5
5
  import type { ConsumersDB } from '../store-reader/store-reader.ts';
6
6
  import type { TableRow } from '../tables/table-row.ts';
7
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
- >;
8
+ // The Kysely tables of a set of consumers, from their declarations. No consumers give `unknown`,
9
+ // which adds no table to an intersection, where the intersection of an empty union would give
10
+ // `never`.
11
+ export type StoreTables<Consumers extends readonly ConsumerDeclaration[]> =
12
+ Consumers extends readonly []
13
+ ? unknown
14
+ : Simplify<
15
+ UnionToIntersection<
16
+ {
17
+ [Index in keyof Consumers]: {
18
+ -readonly [Table in keyof Consumers[Index]['tables']]: TableRow<
19
+ Consumers[Index]['tables'][Table]
20
+ >;
21
+ };
22
+ }[number]
23
+ >
24
+ >;
20
25
 
21
26
  export interface QueryCall<Input, Consumers extends readonly ConsumerDeclaration[]> {
22
27
  input: Input;
package/src/send/types.ts CHANGED
@@ -17,9 +17,9 @@ export type OutgoingFact<Source extends SourceDefinition> = FactBatchBody['facts
17
17
  }[keyof Family['types'] & string];
18
18
  }[Source['families'][number]['stream']];
19
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.
20
+ // A 502, 503 or 504 answer, a timeout and no connection are a `retry`: send the same batch again,
21
+ // unchanged. Every other answer that is no success is `refused`, 500 included: ingest answers 500
22
+ // for a batch that no resend of the same bytes can store.
23
23
  export type SendFailure =
24
24
  | { kind: 'refused'; status: number; reason: string }
25
25
  | { kind: 'retry'; reason: string };
@@ -1,4 +1,4 @@
1
- import { toTagged } from '@zgeoff/std';
1
+ import { isRecord, toTagged } from '@zgeoff/std';
2
2
  import { z } from 'zod';
3
3
  import { buildEventFactID } from '../facts/build-event-fact-id.ts';
4
4
  import type { EntityID, StreamName } from '../ids/types.ts';
@@ -9,6 +9,7 @@ import type {
9
9
  FactFamily,
10
10
  PendingFact,
11
11
  Received,
12
+ Sender,
12
13
  SourceContext,
13
14
  } from './types.ts';
14
15
 
@@ -47,7 +48,7 @@ export function readFactBatch(delivery: Delivery, source: SourceContext): Receiv
47
48
  const facts: PendingFact[] = [];
48
49
 
49
50
  for (const [index, fact] of body.data.facts.entries()) {
50
- const built = buildPendingFact(fact, source);
51
+ const built = buildPendingFact(fact, source, delivery.sender);
51
52
 
52
53
  if (!built.ok) {
53
54
  return { kind: 'refused', status: 422, reason: `fact ${index}: ${built.failure}` };
@@ -71,7 +72,11 @@ function parseJSON(text: string): Result<unknown, null> {
71
72
 
72
73
  type BatchFact = FactBatchBody['facts'][number];
73
74
 
74
- function buildPendingFact(fact: BatchFact, source: SourceContext): Result<PendingFact, string> {
75
+ function buildPendingFact(
76
+ fact: BatchFact,
77
+ source: SourceContext,
78
+ sender: Sender | undefined,
79
+ ): Result<PendingFact, string> {
75
80
  const family = findFamily(source.families, fact.type);
76
81
 
77
82
  if (family === undefined) {
@@ -82,7 +87,13 @@ function buildPendingFact(fact: BatchFact, source: SourceContext): Result<Pendin
82
87
  return { ok: false, failure: `the entity is not of the kind ${family.entityKind}` };
83
88
  }
84
89
 
85
- const payload = family.types[fact.type]?.safeParse(fact.payload);
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);
86
97
 
87
98
  if (payload?.success !== true) {
88
99
  return { ok: false, failure: `the payload does not fit the type ${fact.type}` };
@@ -102,7 +113,7 @@ function buildPendingFact(fact: BatchFact, source: SourceContext): Result<Pendin
102
113
  at: fact.at,
103
114
 
104
115
  // the payload as sent: a schema may strip or reshape, and the log keeps what arrived
105
- payload: fact.payload,
116
+ payload: sent.value,
106
117
  },
107
118
  blobs: [],
108
119
  },
@@ -112,3 +123,23 @@ function buildPendingFact(fact: BatchFact, source: SourceContext): Result<Pendin
112
123
  function findFamily(families: readonly FactFamily[], type: string): FactFamily | undefined {
113
124
  return families.find((family) => Object.hasOwn(family.types, type));
114
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
+ }
@@ -1,5 +1,5 @@
1
1
  import type { z } from 'zod';
2
- import type { BlobHash, StreamName } from '../ids/types.ts';
2
+ import type { BlobHash, EntityID, StreamName } from '../ids/types.ts';
3
3
  import type { EntityKind } from '../link-types/types.ts';
4
4
  import type { DeclaredStreamName, FactEnvelope } from '../types.ts';
5
5
 
@@ -122,6 +122,18 @@ export interface PullPath {
122
122
 
123
123
  export interface PullContext {
124
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;
125
137
  }
126
138
 
127
139
  export type Pulled =
@@ -151,4 +163,8 @@ export interface PendingFact {
151
163
  stream: StreamName;
152
164
  fact: FactEnvelope<unknown>;
153
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;
154
170
  }