1import * as GCP from "alchemy/GCP";
2import * as Clock from "effect/Clock";
3import * as Effect from "effect/Effect";
4import {
5 Orders,
6 Reports,
7 summaryKey,
8 type DailySummary,
9 type RegionTotal,
10} from "./resources.ts";
11
12const DAY_MS = 24 * 60 * 60 * 1000;
13
14/**
15 * The nightly batch: roll the last 24 hours of orders up into one JSON
16 * summary in Cloud Storage.
17 *
18 * A Cloud Run Job runs to completion and exits, so nothing is billed
19 * between runs. It is started by Cloud Scheduler every night and by the
20 * admin route in {@link ../Admin.ts} on demand; both paths create a new
21 * execution through the Cloud Run Admin API.
22 *
23 * Re-running is safe: a run for the same day overwrites that day's
24 * object with a fresh aggregate.
25 */
26export default class Summarize extends GCP.Run.Job<Summarize>()(
27 "Summarize",
28 {
29 main: import.meta.url,
30 // A failed summary is retried once, then the execution fails and the
31 // next trigger tries again.
32 maxRetries: 1,
33 timeout: "300s",
34 },
35 Effect.gen(function* () {
36 const table = yield* Orders;
37 const orders = yield* GCP.BigQuery.ReadTable(table);
38 const reports = yield* GCP.Storage.WriteBucket(Reports);
39 // The table id is bound at deploy time and read inside `run`.
40 const tableId = yield* table.tableId;
41
42 return {
43 run: Effect.gen(function* () {
44 const to = new Date(yield* Clock.currentTimeMillis);
45 const from = new Date(to.getTime() - DAY_MS);
46 // Cloud Run names each execution in the task's environment.
47 const execution = yield* Effect.sync(
48 () => process.env.CLOUD_RUN_EXECUTION ?? null,
49 );
50
51 // Unqualified table names resolve against the table's dataset.
52 const rows = yield* orders.query(
53 `SELECT region, COUNT(*) AS orders, SUM(amountCents) AS revenueCents
54 FROM \`${yield* tableId}\`
55 WHERE occurredAt >= TIMESTAMP(@from) AND occurredAt < TIMESTAMP(@to)
56 GROUP BY region
57 ORDER BY region`,
58 { from: from.toISOString(), to: to.toISOString() },
59 );
60
61 const regions: RegionTotal[] = rows.map((row) => ({
62 region: String(row.region),
63 orders: Number(row.orders),
64 revenueCents: Number(row.revenueCents ?? 0),
65 }));
66
67 const summary: DailySummary = {
68 from: from.toISOString(),
69 to: to.toISOString(),
70 execution,
71 orders: regions.reduce((n, r) => n + r.orders, 0),
72 revenueCents: regions.reduce((n, r) => n + r.revenueCents, 0),
73 regions,
74 };
75
76 const key = summaryKey(to);
77 yield* reports.put(key, JSON.stringify(summary, null, 2), {
78 contentType: "application/json",
79 });
80 yield* Effect.log(
81 `summarize: ${summary.orders} order(s) in ${regions.length} region(s) -> ${key}`,
82 );
83 }).pipe(Effect.orDie),
84 };
85 }).pipe(
86 Effect.provide([GCP.BigQuery.ReadTableHttp, GCP.Storage.WriteBucketHttp]),
87 ),
88) {}