@boxxy/pepys 0.0.1 → 0.1.4

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 (63) hide show
  1. package/README.md +42 -5
  2. package/dist/boxxy-source.d.ts +636 -0
  3. package/dist/boxxy-work-plugin.d.ts +1 -0
  4. package/dist/build-boxxy-plugin.d.ts +181 -0
  5. package/dist/consumers/apply-scope.d.ts +5 -0
  6. package/dist/create-pepys-fact-sink.d.ts +9 -0
  7. package/dist/create-pepys-reader.d.ts +9 -0
  8. package/dist/generated/boxxy-work-boundary.d.ts +566 -0
  9. package/dist/index.d.ts +5 -0
  10. package/dist/queries/build-cost-to-land-query.d.ts +156 -0
  11. package/dist/queries/build-cost-to-land.d.ts +12 -0
  12. package/dist/queries/build-stage-time-query.d.ts +29 -0
  13. package/dist/queries/build-stage-time.d.ts +10 -0
  14. package/dist/queries/constants.d.ts +6 -0
  15. package/dist/queries/find-item-entity-id.d.ts +2 -0
  16. package/dist/queries/types.d.ts +109 -0
  17. package/dist/sink/is-outgoing-fact.d.ts +3 -0
  18. package/dist/sink/pick-sink-answer.d.ts +7 -0
  19. package/dist/sink/to-wire-fact.d.ts +5 -0
  20. package/dist/types.d.ts +6 -0
  21. package/dist/wire-fact-schemas.d.ts +319 -0
  22. package/dist/work/boxxy-work-consumer.d.ts +125 -0
  23. package/dist/work/constants.d.ts +3 -0
  24. package/dist/work/fold-work-facts.d.ts +2 -0
  25. package/dist/work/types.d.ts +147 -0
  26. package/package.json +51 -4
  27. package/src/boxxy-source.ts +25 -0
  28. package/src/boxxy-work-plugin.ts +11 -0
  29. package/src/build-boxxy-plugin.ts +18 -0
  30. package/src/consumers/apply-scope.ts +125 -0
  31. package/src/create-pepys-fact-sink.ts +64 -0
  32. package/src/create-pepys-reader.ts +79 -0
  33. package/src/generated/boxxy-work-boundary.ts +2923 -0
  34. package/src/generated/boxxy-work.sql +71 -0
  35. package/src/index.ts +17 -0
  36. package/src/queries/build-cost-to-land-query.ts +127 -0
  37. package/src/queries/build-cost-to-land.ts +226 -0
  38. package/src/queries/build-stage-time-query.ts +59 -0
  39. package/src/queries/build-stage-time.ts +195 -0
  40. package/src/queries/constants.ts +10 -0
  41. package/src/queries/find-item-entity-id.ts +16 -0
  42. package/src/queries/types.ts +168 -0
  43. package/src/sink/is-outgoing-fact.ts +19 -0
  44. package/src/sink/pick-sink-answer.ts +29 -0
  45. package/src/sink/to-wire-fact.ts +31 -0
  46. package/src/test-utils/build-foreign-stand-in.ts +139 -0
  47. package/src/test-utils/build-mock-applied-fact.ts +39 -0
  48. package/src/test-utils/build-mock-attempt-read-row.ts +17 -0
  49. package/src/test-utils/build-mock-binding-read-row.ts +12 -0
  50. package/src/test-utils/build-mock-item-facts.ts +149 -0
  51. package/src/test-utils/build-mock-request-read-row.ts +20 -0
  52. package/src/test-utils/build-mock-scope-fact.ts +13 -0
  53. package/src/test-utils/create-ingest-stand-in.ts +56 -0
  54. package/src/test-utils/create-query-stand-in.ts +51 -0
  55. package/src/test-utils/create-test-pepys.ts +228 -0
  56. package/src/test-utils/to-entity-id.ts +14 -0
  57. package/src/test-utils/to-scope-fact.ts +92 -0
  58. package/src/types.ts +14 -0
  59. package/src/wire-fact-schemas.ts +41 -0
  60. package/src/work/boxxy-work-consumer.ts +95 -0
  61. package/src/work/constants.ts +25 -0
  62. package/src/work/fold-work-facts.ts +490 -0
  63. package/src/work/types.ts +177 -0
@@ -0,0 +1,25 @@
1
+ import { defineFactFamily, defineSource, receiveFactBatch } from '@pepys/kit';
2
+ import { wireFactSchemas } from './wire-fact-schemas.ts';
3
+
4
+ // Every boxxy fact names its item as the entity, so the family's entity kind is `boxxy:item`.
5
+ export const workFamily = defineFactFamily({
6
+ stream: 'facts.boxxy.work',
7
+ api: 'boxxy/1',
8
+ entityKind: 'boxxy:item',
9
+ types: wireFactSchemas,
10
+ });
11
+
12
+ // The path keeps the kit's limits, 100 facts and 1 MiB a batch, which hold the outbox's own
13
+ // batch budget.
14
+ export const boxxySource = defineSource({
15
+ name: 'boxxy',
16
+ families: [workFamily],
17
+ live: receiveFactBatch({
18
+ route: '/boxxy/facts',
19
+ check: {
20
+ kind: 'hmac-sha256',
21
+ header: 'x-boxxy-signature-256',
22
+ secretEnv: 'PEPYS_BOXXY_SIGNING_SECRET',
23
+ },
24
+ }),
25
+ });
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@pepys/kit';
2
+ import { boxxySource } from './boxxy-source.ts';
3
+ import { boxxyWorkConsumer } from './work/boxxy-work-consumer.ts';
4
+
5
+ // The source and the consumer without the queries, which need the GitHub and telemetry consumers.
6
+ // `pepys build` writes the consumer's glue from this plug-in.
7
+ export const boxxyWorkPlugin = definePlugin({
8
+ name: 'boxxy',
9
+ sources: [boxxySource],
10
+ consumers: [boxxyWorkConsumer],
11
+ });
@@ -0,0 +1,18 @@
1
+ import { definePlugin } from '@pepys/kit';
2
+ import { boxxySource } from './boxxy-source.ts';
3
+ import { buildCostToLandQuery } from './queries/build-cost-to-land-query.ts';
4
+ import { buildStageTimeQuery } from './queries/build-stage-time-query.ts';
5
+ import type { ForeignReads } from './queries/types.ts';
6
+ import { boxxyWorkConsumer } from './work/boxxy-work-consumer.ts';
7
+
8
+ // The boxxy plug-in of a pepys deployment: its source, its consumer and its two queries. The
9
+ // deployment passes the GitHub and telemetry consumers that the queries read, and deploys their
10
+ // plug-ins beside this one.
11
+ export function buildBoxxyPlugin(foreign: ForeignReads) {
12
+ return definePlugin({
13
+ name: 'boxxy',
14
+ sources: [boxxySource],
15
+ consumers: [boxxyWorkConsumer],
16
+ queries: [buildCostToLandQuery(foreign), buildStageTimeQuery(foreign.delivery)],
17
+ });
18
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ initBoxxyWorkModel,
3
+ linkAttemptOf,
4
+ linkProduced,
5
+ linkRanIn,
6
+ parseBoxxyItemID,
7
+ readScopeFacts,
8
+ toBoxxyAttemptID,
9
+ toGithubPullRequestID,
10
+ toTelemetrySessionID,
11
+ updateBoxxyWorkModel,
12
+ } from '../generated/boxxy-work-boundary.ts';
13
+ import type {
14
+ BoxxyItemID,
15
+ BoxxyWorkMessage,
16
+ BoxxyWorkModel,
17
+ BoxxyWorkOperation,
18
+ } from '../generated/boxxy-work-boundary.ts';
19
+ import { foldWorkFacts } from '../work/fold-work-facts.ts';
20
+ import type { AttemptFold, WorkFold } from '../work/types.ts';
21
+
22
+ export type { BoxxyWorkMessage, BoxxyWorkModel } from '../generated/boxxy-work-boundary.ts';
23
+
24
+ // scriptc takes the model and message types through a re-export, but not the two functions
25
+ export function init(): BoxxyWorkModel {
26
+ return initBoxxyWorkModel();
27
+ }
28
+
29
+ export function update(model: BoxxyWorkModel, message: BoxxyWorkMessage): BoxxyWorkModel {
30
+ return updateBoxxyWorkModel(model, message);
31
+ }
32
+
33
+ // Every row and link of one item, from every fact of the item.
34
+ export function applyScope(scope: string): string {
35
+ const item = parseBoxxyItemID(scope);
36
+
37
+ if (item === undefined) {
38
+ return JSON.stringify([]);
39
+ }
40
+
41
+ const fold = foldWorkFacts(readScopeFacts(item));
42
+
43
+ return JSON.stringify(buildItemOperations(item, fold));
44
+ }
45
+
46
+ function buildItemOperations(item: BoxxyItemID, fold: WorkFold): BoxxyWorkOperation[] {
47
+ const operations: BoxxyWorkOperation[] = [];
48
+
49
+ if (fold.item !== null) {
50
+ operations.push({ upsert: { boxxy_item: { ...fold.item, id: item } } });
51
+ }
52
+
53
+ for (const attempt of fold.attempts) {
54
+ operations.push(...buildAttemptOperations(item, attempt));
55
+ }
56
+
57
+ return operations;
58
+ }
59
+
60
+ function buildAttemptOperations(item: BoxxyItemID, attempt: AttemptFold): BoxxyWorkOperation[] {
61
+ const id = toBoxxyAttemptID(attempt.id);
62
+
63
+ const operations: BoxxyWorkOperation[] = [
64
+ {
65
+ upsert: {
66
+ boxxy_attempt: {
67
+ id,
68
+ item_id: item,
69
+ phase: attempt.phase,
70
+ number: attempt.number,
71
+ round: attempt.round,
72
+ mode: attempt.mode,
73
+ pool: attempt.pool,
74
+ recipe_hash: attempt.recipe_hash,
75
+ rules_version: attempt.rules_version,
76
+ atc_session: attempt.atc_session,
77
+ state: attempt.state,
78
+ outcome: attempt.outcome,
79
+ failure: attempt.failure,
80
+ claimed_at: attempt.claimed_at,
81
+ started_at: attempt.started_at,
82
+ closed_out_at: attempt.closed_out_at,
83
+ ended_at: attempt.ended_at,
84
+ sleeps: attempt.sleeps,
85
+ updated_at: attempt.updated_at,
86
+ },
87
+ },
88
+ },
89
+ linkAttemptOf({ from: id, to: item, confidence: 1, evidence: attempt.claimedFact }),
90
+ ];
91
+
92
+ for (const bound of attempt.sessions) {
93
+ const session = toTelemetrySessionID(bound.session);
94
+
95
+ operations.push(
96
+ {
97
+ upsert: {
98
+ boxxy_attempt_session: {
99
+ attempt_id: id,
100
+ session_id: session,
101
+ key: bound.key,
102
+ product: bound.product,
103
+ caller_issuer: bound.caller_issuer,
104
+ caller_subject: bound.caller_subject,
105
+ bound_at: bound.bound_at,
106
+ },
107
+ },
108
+ },
109
+ linkRanIn({ from: id, to: session, confidence: 1, evidence: bound.evidence }),
110
+ );
111
+ }
112
+
113
+ for (const pullRequest of attempt.pullRequests) {
114
+ operations.push(
115
+ linkProduced({
116
+ from: id,
117
+ to: toGithubPullRequestID(pullRequest.id),
118
+ confidence: 1,
119
+ evidence: pullRequest.evidence,
120
+ }),
121
+ );
122
+ }
123
+
124
+ return operations;
125
+ }
@@ -0,0 +1,64 @@
1
+ import type { FactSink } from '@boxxy/model';
2
+ import { createFactBatchSender } from '@pepys/kit/send';
3
+ import { boxxySource } from './boxxy-source.ts';
4
+ import { isOutgoingFact } from './sink/is-outgoing-fact.ts';
5
+ import { pickSinkAnswer } from './sink/pick-sink-answer.ts';
6
+ import { toWireFact } from './sink/to-wire-fact.ts';
7
+
8
+ interface PepysFactSinkOptions {
9
+ // the URL of the source's receive route on pepys ingest
10
+ readonly url: string;
11
+
12
+ // the value of `PEPYS_BOXXY_SIGNING_SECRET` in the pepys deployment
13
+ readonly secret: string;
14
+ readonly timeoutMs: number;
15
+
16
+ // the global `fetch` when left out; a test passes ingest's route
17
+ readonly fetch?: (request: Request) => Promise<Response>;
18
+ }
19
+
20
+ // The sink holds no HTTP code: the kit's sender builds the body, signs it and posts it. The sender
21
+ // takes no abort signal of the caller's, so the sink races it with a timer; a late answer is
22
+ // dropped, and the rows stay unsent. The kit's sender throws on an empty secret.
23
+ export function createPepysFactSink(options: PepysFactSinkOptions): FactSink {
24
+ const send = createFactBatchSender({
25
+ source: boxxySource,
26
+ url: options.url,
27
+ secret: options.secret,
28
+ fetch: options.fetch,
29
+ });
30
+
31
+ return {
32
+ send: async (facts) => {
33
+ const wire = facts.map((fact) => toWireFact(fact));
34
+ const outgoing = wire.filter((fact) => isOutgoingFact(fact));
35
+
36
+ // ingest would refuse the batch whole with 422 for the same fact
37
+ if (outgoing.length < wire.length) {
38
+ return {
39
+ kind: 'refused-batch',
40
+ status: 422,
41
+ reason: 'a fact does not fit its type in the boxxy fact family',
42
+ };
43
+ }
44
+
45
+ const sent = await withTimeout(send(outgoing), options.timeoutMs);
46
+
47
+ return pickSinkAnswer(sent, options.timeoutMs);
48
+ },
49
+ };
50
+ }
51
+
52
+ async function withTimeout<Value>(sending: Promise<Value>, timeoutMs: number) {
53
+ const timer = Promise.withResolvers<'timeout'>();
54
+
55
+ const handle = setTimeout(() => {
56
+ timer.resolve('timeout');
57
+ }, timeoutMs);
58
+
59
+ try {
60
+ return await Promise.race([sending, timer.promise]);
61
+ } finally {
62
+ clearTimeout(handle);
63
+ }
64
+ }
@@ -0,0 +1,79 @@
1
+ import { costToLandSchema, stageTimeSchema } from '@boxxy/model';
2
+ import type { ItemId, PepysAnswer, PepysReader } from '@boxxy/model';
3
+ import type { z } from 'zod';
4
+ import type { UncheckedQueryClient } from './types.ts';
5
+
6
+ interface PepysReaderOptions {
7
+ readonly client: UncheckedQueryClient;
8
+
9
+ // the environment whose deploy counts in the stage times, such as `production`
10
+ readonly environment: string;
11
+ readonly timeoutMs: number;
12
+ }
13
+
14
+ // The port takes boxxy's bare item ID; the queries take the item's entity ID. The reader never
15
+ // throws and never retries: a report is a read that the owner can ask again.
16
+ export function createPepysReader(options: PepysReaderOptions): PepysReader {
17
+ const ask = makeAsk(options.timeoutMs);
18
+
19
+ return {
20
+ readCostToLand: (item) =>
21
+ ask(costToLandSchema, () => options.client.boxxy_cost_to_land({ item: toItemEntity(item) })),
22
+ readStageTime: (item) =>
23
+ ask(stageTimeSchema, () =>
24
+ options.client.boxxy_stage_time({
25
+ item: toItemEntity(item),
26
+ environment: options.environment,
27
+ }),
28
+ ),
29
+ };
30
+ }
31
+
32
+ // An answer that parses is the value. An error, a timeout, no connection, or an answer that the
33
+ // schema refuses is `unavailable` with a reason. A refused answer means that the pepys deployment
34
+ // runs another version of this package than the boxxy server.
35
+ function makeAsk(timeoutMs: number) {
36
+ return async <Value>(
37
+ schema: z.ZodType<Value>,
38
+ call: () => Promise<unknown>,
39
+ ): Promise<PepysAnswer<Value>> => {
40
+ const answer = await withTimeout(call, timeoutMs);
41
+
42
+ if (answer.kind === 'unavailable') {
43
+ return answer;
44
+ }
45
+
46
+ const parsed = schema.safeParse(answer.value);
47
+
48
+ return parsed.success
49
+ ? { kind: 'value', value: parsed.data }
50
+ : { kind: 'unavailable', reason: 'pepys answered a shape this boxxy does not know' };
51
+ };
52
+ }
53
+
54
+ async function withTimeout(
55
+ call: () => Promise<unknown>,
56
+ timeoutMs: number,
57
+ ): Promise<PepysAnswer<unknown>> {
58
+ const timer = Promise.withResolvers<PepysAnswer<unknown>>();
59
+
60
+ const handle = setTimeout(() => {
61
+ timer.resolve({ kind: 'unavailable', reason: `no answer in ${timeoutMs} ms` });
62
+ }, timeoutMs);
63
+
64
+ try {
65
+ return await Promise.race([
66
+ call().then(
67
+ (value): PepysAnswer<unknown> => ({ kind: 'value', value }),
68
+ (error: unknown): PepysAnswer<unknown> => ({ kind: 'unavailable', reason: String(error) }),
69
+ ),
70
+ timer.promise,
71
+ ]);
72
+ } finally {
73
+ clearTimeout(handle);
74
+ }
75
+ }
76
+
77
+ function toItemEntity(item: ItemId): string {
78
+ return `boxxy:item:${item}`;
79
+ }