cursedbelt-server 4.18.0 → 4.19.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 (80) hide show
  1. package/dist/server/activity/index.d.ts +2 -1
  2. package/dist/server/activity/index.js +2 -1
  3. package/dist/server/auth/passwordCost.d.ts +21 -0
  4. package/dist/server/auth/passwordCost.js +80 -0
  5. package/dist/server/bench/index.d.ts +1 -0
  6. package/dist/server/bench/index.js +1 -0
  7. package/dist/server/bench/tail.d.ts +110 -0
  8. package/dist/server/bench/tail.js +182 -0
  9. package/dist/server/d1/index.d.ts +1 -2
  10. package/dist/server/d1/index.js +9 -9
  11. package/dist/server/d1/pullD1.js +16 -3
  12. package/dist/server/engagement/api.d.ts +71 -0
  13. package/dist/server/engagement/api.js +84 -0
  14. package/dist/server/engagement/env.d.ts +18 -0
  15. package/dist/server/engagement/env.js +52 -0
  16. package/dist/server/engagement/index.d.ts +55 -0
  17. package/dist/server/engagement/index.js +55 -0
  18. package/dist/server/engagement/places.d.ts +22 -0
  19. package/dist/server/engagement/places.js +63 -0
  20. package/dist/server/engagement/policy.d.ts +168 -0
  21. package/dist/server/engagement/policy.js +202 -0
  22. package/dist/server/engagement/store.d.ts +92 -0
  23. package/dist/server/engagement/store.js +223 -0
  24. package/dist/server/engagement/summary.d.ts +102 -0
  25. package/dist/server/engagement/summary.js +127 -0
  26. package/dist/server/engagement/types.d.ts +42 -0
  27. package/dist/server/engagement/types.js +12 -0
  28. package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
  29. package/dist/server/maps-budget/mapsBudget.js +193 -0
  30. package/dist/server/satellite/config.d.ts +173 -0
  31. package/dist/server/satellite/config.js +259 -0
  32. package/dist/server/satellite/door.d.ts +112 -0
  33. package/dist/server/satellite/door.js +149 -0
  34. package/dist/server/storage/binaryStore.d.ts +18 -0
  35. package/dist/server/storage/binaryStore.js +32 -1
  36. package/dist/server/storage/derivatives.d.ts +253 -0
  37. package/dist/server/storage/derivatives.js +266 -0
  38. package/dist/server/storage/uploadSession.d.ts +75 -0
  39. package/dist/server/storage/uploadSession.js +74 -0
  40. package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
  41. package/docs/activity.md +43 -0
  42. package/docs/engagement.md +47 -0
  43. package/docs/notifications.md +43 -0
  44. package/docs/retention.md +81 -0
  45. package/docs/skipped-tests.md +19 -0
  46. package/package.json +46 -9
  47. package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
  48. package/src/leafSubpathsImportNothing.spec.ts +49 -0
  49. package/src/server/activity/index.ts +2 -1
  50. package/src/server/auth/passwordCost.spec.ts +42 -0
  51. package/src/server/auth/passwordCost.ts +87 -0
  52. package/src/server/bench/index.ts +13 -0
  53. package/src/server/bench/tail.spec.ts +126 -0
  54. package/src/server/bench/tail.ts +237 -0
  55. package/src/server/d1/index.ts +9 -9
  56. package/src/server/d1/pullD1.spec.ts +20 -0
  57. package/src/server/d1/pullD1.ts +18 -2
  58. package/src/server/engagement/api.ts +119 -0
  59. package/src/server/engagement/engagement.spec.ts +462 -0
  60. package/src/server/engagement/env.ts +73 -0
  61. package/src/server/engagement/index.ts +92 -0
  62. package/src/server/engagement/places.ts +76 -0
  63. package/src/server/engagement/policy.ts +250 -0
  64. package/src/server/engagement/store.ts +272 -0
  65. package/src/server/engagement/summary.ts +216 -0
  66. package/src/server/engagement/types.ts +61 -0
  67. package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
  68. package/src/server/maps-budget/mapsBudget.ts +304 -0
  69. package/src/server/satellite/config.ts +389 -0
  70. package/src/server/satellite/door.ts +169 -0
  71. package/src/server/satellite/satellite.spec.ts +161 -0
  72. package/src/server/storage/binaryStore.ts +31 -1
  73. package/src/server/storage/derivatives.spec.ts +125 -0
  74. package/src/server/storage/derivatives.ts +329 -0
  75. package/src/server/storage/uploadSession.spec.ts +132 -0
  76. package/src/server/storage/uploadSession.ts +114 -0
  77. package/dist/server/d1/kysely.d.ts +0 -56
  78. package/dist/server/d1/kysely.js +0 -138
  79. package/src/server/d1/kysely.spec.ts +0 -145
  80. package/src/server/d1/kysely.ts +0 -169
@@ -0,0 +1,127 @@
1
+ /**
2
+ * One app's engagement, folded into the shape the console draws.
3
+ *
4
+ * ── Why the APP does the folding ────────────────────────────────────────────
5
+ * The same argument `metrics/inventory.ts` makes: the console runs on this Mac
6
+ * and most of the fleet runs on a checkout-less box reachable only over HTTPS,
7
+ * so the app that owns the data is the only thing that can read it. Shipping
8
+ * raw daily rows would also mean shipping the per-day timeline of a household's
9
+ * private apps across the wire on every poll, when the console draws totals.
10
+ *
11
+ * ── Everything here is pure over rows ───────────────────────────────────────
12
+ * `summarize` takes the three roll-ups and a clock, so `engagement.test.ts`
13
+ * drives it with hand-written rows and checks arithmetic a human can verify —
14
+ * which is the whole design constraint at n = 3. No I/O, no `Date.now()`.
15
+ */
16
+ import { classifyInterest, dayKey, } from "./types.js";
17
+ export function summarize(input) {
18
+ const { app, now } = input;
19
+ const users = input.users.map((u) => ({
20
+ userKey: u.userKey,
21
+ views: u.views,
22
+ sessions: u.sessions,
23
+ activeMs: u.activeMs,
24
+ firstTs: u.firstTs,
25
+ lastTs: u.lastTs,
26
+ places: u.places,
27
+ interest: classifyInterest({ lastSeenTs: u.lastTs, sessions: u.sessions }, now),
28
+ daysSinceSeen: Math.max(0, Math.floor((now - u.lastTs) / 86_400_000)),
29
+ }));
30
+ // Fold the (user, place) rows down the USER axis to get the per-feature view.
31
+ const byPlace = new Map();
32
+ for (const row of input.places) {
33
+ const existing = byPlace.get(row.place);
34
+ if (existing) {
35
+ existing.users += 1;
36
+ existing.views += row.views;
37
+ existing.sessions += row.sessions;
38
+ existing.activeMs += row.activeMs;
39
+ existing.lastTs = Math.max(existing.lastTs, row.lastTs);
40
+ }
41
+ else {
42
+ byPlace.set(row.place, {
43
+ place: row.place,
44
+ users: 1,
45
+ views: row.views,
46
+ sessions: row.sessions,
47
+ activeMs: row.activeMs,
48
+ lastTs: row.lastTs,
49
+ msPerView: 0,
50
+ });
51
+ }
52
+ }
53
+ const places = [...byPlace.values()].map((p) => ({
54
+ ...p,
55
+ // Integer ms: a mean over three people does not deserve decimals, and a
56
+ // whole number is one the owner can sanity-check against a stopwatch.
57
+ msPerView: p.views > 0 ? Math.round(p.activeMs / p.views) : 0,
58
+ }));
59
+ places.sort((a, b) => b.views - a.views || a.place.localeCompare(b.place));
60
+ // Distinct users per day needs the (user, place, day) grain, which is exactly
61
+ // why `days()` returns it rather than pre-summed days.
62
+ const dayAcc = new Map();
63
+ for (const row of input.days) {
64
+ let bucket = dayAcc.get(row.day);
65
+ if (!bucket) {
66
+ bucket = { views: 0, sessions: 0, users: new Set() };
67
+ dayAcc.set(row.day, bucket);
68
+ }
69
+ bucket.views += row.views;
70
+ bucket.sessions += row.sessions;
71
+ bucket.users.add(row.userKey);
72
+ }
73
+ const daily = [...dayAcc.entries()]
74
+ .map(([day, b]) => ({ day, views: b.views, sessions: b.sessions, users: b.users.size }))
75
+ .sort((a, b) => a.day.localeCompare(b.day));
76
+ const userPlaces = input.places
77
+ .map((p) => ({
78
+ userKey: p.userKey,
79
+ place: p.place,
80
+ views: p.views,
81
+ activeMs: p.activeMs,
82
+ lastTs: p.lastTs,
83
+ }))
84
+ .sort((a, b) => b.views - a.views);
85
+ return {
86
+ app,
87
+ generatedAt: now,
88
+ users,
89
+ places,
90
+ userPlaces,
91
+ daily,
92
+ totals: {
93
+ users: users.length,
94
+ views: users.reduce((n, u) => n + u.views, 0),
95
+ sessions: users.reduce((n, u) => n + u.sessions, 0),
96
+ activeMs: users.reduce((n, u) => n + u.activeMs, 0),
97
+ returning: users.filter((u) => u.sessions > 1).length,
98
+ oneTime: users.filter((u) => u.sessions <= 1).length,
99
+ activeUsers: users.filter((u) => u.interest === "active").length,
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * The return curve: of the accounts first seen on a given day, how many came
105
+ * back at least once afterwards.
106
+ *
107
+ * Stated as WHOLE ACCOUNTS on a named cohort day rather than as a percentage,
108
+ * because a percentage of one person is a number that lies confidently. The
109
+ * console renders "2 of 3 came back" for the same reason.
110
+ */
111
+ export function returnCurve(users, now, windowDays = 30) {
112
+ const cutoff = now - windowDays * 86_400_000;
113
+ const acc = new Map();
114
+ for (const u of users) {
115
+ if (u.firstTs < cutoff)
116
+ continue;
117
+ const day = dayKey(u.firstTs);
118
+ const bucket = acc.get(day) ?? { joined: 0, returned: 0 };
119
+ bucket.joined += 1;
120
+ if (u.sessions > 1)
121
+ bucket.returned += 1;
122
+ acc.set(day, bucket);
123
+ }
124
+ return [...acc.entries()]
125
+ .map(([day, b]) => ({ day, ...b }))
126
+ .sort((a, b) => a.day.localeCompare(b.day));
127
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The row shapes the engagement store reads and writes, plus the pure policy
3
+ * re-exported through one door.
4
+ *
5
+ * Split out of `store.ts` so `summary.ts` — and the console's BROWSER code,
6
+ * which types the fetched payload — can import them without pulling in
7
+ * `bun:sqlite`. A type-only import would be erased, but the console also wants
8
+ * `classifyInterest` and `dayKey` at runtime to label a row it is rendering,
9
+ * and importing those from the store module would drag a native sqlite binding
10
+ * into a Vite build.
11
+ */
12
+ export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, type InterestVerdict, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, resolvePlace, SESSION_GAP_MS, type ViewFold, } from "./policy.js";
13
+ /** One (user, place, UTC day) bucket. */
14
+ export interface EngagementDay {
15
+ userKey: string;
16
+ place: string;
17
+ day: string;
18
+ views: number;
19
+ activeMs: number;
20
+ sessions: number;
21
+ }
22
+ /** One (user, place) lifetime roll-up — what the retention answers read. */
23
+ export interface EngagementUserPlace {
24
+ userKey: string;
25
+ place: string;
26
+ views: number;
27
+ sessions: number;
28
+ activeMs: number;
29
+ firstTs: number;
30
+ lastTs: number;
31
+ }
32
+ /** One account's relationship with the whole app. */
33
+ export interface EngagementUser {
34
+ userKey: string;
35
+ views: number;
36
+ sessions: number;
37
+ activeMs: number;
38
+ firstTs: number;
39
+ lastTs: number;
40
+ /** Distinct places this account has ever opened in this app. */
41
+ places: number;
42
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The row shapes the engagement store reads and writes, plus the pure policy
3
+ * re-exported through one door.
4
+ *
5
+ * Split out of `store.ts` so `summary.ts` — and the console's BROWSER code,
6
+ * which types the fetched payload — can import them without pulling in
7
+ * `bun:sqlite`. A type-only import would be erased, but the console also wants
8
+ * `classifyInterest` and `dayKey` at runtime to label a row it is rendering,
9
+ * and importing those from the store module would drag a native sqlite binding
10
+ * into a Vite build.
11
+ */
12
+ export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, resolvePlace, SESSION_GAP_MS, } from "./policy.js";
@@ -0,0 +1,194 @@
1
+ /**
2
+ * A HARD, persisted call ceiling for Google Maps Platform — monthly AND daily.
3
+ *
4
+ * ── Why this is a package and not a helper in one app ───────────────────────
5
+ * Two apps had their own copy of it — `apps/orch` (leave-by times) and
6
+ * `apps/family` (the atlas) — and two consumers is exactly the threshold the
7
+ * placement law names. The reason to promote rather than to leave them is
8
+ * sharper than tidiness: **the guarantee is that the ceiling and the network
9
+ * call cannot be separated**, and a rule living in two files that can drift is a
10
+ * guarantee with two chances to stop being one. A THIRD copy was the real risk.
11
+ *
12
+ * ── 🔴 `cursedbelt-server/maps-budget`, since 4.19.0 (task 345) ──────────────
13
+ * It was copied again in THIS generation, under two file names —
14
+ * `apps/family/src/kit/mapsBudget.ts` and `apps/station/src/kit/mapsPlatform.ts`,
15
+ * 13,408 identical bytes that no basename census could see. It lives here now,
16
+ * with its suite, as a leaf that imports nothing: an app injects its own
17
+ * {@link MapsBudgetStore}, so neither app can reach the other's ledger, and the
18
+ * generic `Api` parameter lets each name its own API set without a shared enum.
19
+ * It is in the SERVER tier rather than `cursedbelt-core` because a spend ceiling
20
+ * only means anything where the key is — never in a browser bundle.
21
+ *
22
+ * ── The owner's constraint, verbatim (2026-08-08) ───────────────────────────
23
+ * *"Note I don't want to allow spending with the apis so we have to enforce the
24
+ * limits for the free tiers if applicable."*
25
+ *
26
+ * Maps Platform is unlike the free Google APIs in three ways that together make
27
+ * a client-side ceiling the only real protection:
28
+ *
29
+ * 1. It REQUIRES a billing account. A runaway loop on Gmail or Calendar costs
30
+ * nothing; here it bills.
31
+ * 2. The free allowance is 10,000 events/month per Essentials API. Past that,
32
+ * calls cost money rather than failing.
33
+ * 3. 🔴 **A Cloud BUDGET DOES NOT CAP USAGE — it only alerts.** The only
34
+ * server-side hard stop is a per-API QUOTA in Cloud Console, and that step
35
+ * is the owner's. This is ours.
36
+ *
37
+ * ── 🔴 TWO periods, because a month is not a shape a runaway respects ───────
38
+ * A monthly cap can be burned inside a single day. A loop that starts on the
39
+ * 2nd spends the whole allowance by the 3rd and leaves the API dead for the
40
+ * other 29 — which is the outcome the ceiling exists to PREVENT, arriving by
41
+ * way of the ceiling itself. So every call is measured against both:
42
+ *
43
+ * · the **month**, which protects the free tier and therefore the bill;
44
+ * · the **day**, `dailyCeiling()` of the month's, which catches the loop in
45
+ * hours rather than in a billing period.
46
+ *
47
+ * The daily number is DERIVED from the monthly one and never written down
48
+ * twice. Two tables drift, and this module exists because two copies of this
49
+ * rule already did.
50
+ *
51
+ * Both have to pass, and a refusal by either counts nothing. Expected real
52
+ * volume across ALL APIs is a few dozen calls a day — about 1% of the free
53
+ * allowance — so a tenth of the month still leaves roughly 10× headroom on the
54
+ * busiest single API. A ceiling being hit still means something is broken.
55
+ *
56
+ * ── Two properties that are not negotiable ─────────────────────────────────
57
+ * **PERSISTED, never in memory** — BOTH counters. An in-memory counter resets
58
+ * on every restart, and a crash-loop restarting hourly would reset it hourly —
59
+ * turning the one safeguard into a formality precisely when something is wrong.
60
+ * A daily counter is the one a restart-loop would defeat most cheaply, so it is
61
+ * persisted the same way. Each key embeds its own period, so a new month or a
62
+ * new day starts at zero with no scheduled reset to fail and no "the machine was
63
+ * off on the 1st" hole.
64
+ *
65
+ * **A corrupted counter fails SAFE**, treated as exhausted rather than as zero —
66
+ * the daily one exactly as the monthly one, deliberately. The wrong direction
67
+ * here spends the owner's money, and two fail-safes with different opinions in
68
+ * one module would be worse than either choice.
69
+ *
70
+ * ── What is deliberately NOT here ──────────────────────────────────────────
71
+ * The typed API callers. `apps/family` talks to eight Maps APIs plus Street
72
+ * View; `apps/orch` talks to Routes and Weather. They share no call site, only
73
+ * this rule — and hoisting nine wrappers into a package to give two apps one
74
+ * counter would be the shared-code version of the same mistake this package
75
+ * exists to fix. Each app keeps its own ONE DOOR (`maps.ts` in family, pinned by
76
+ * a test that scans for Google hosts outside it) and both doors consume this.
77
+ */
78
+ /** Where the counter lives. A tiny KV, so an app can back it with whatever
79
+ * durable store it already has — `apps/orch` uses its own settings store,
80
+ * `apps/family` uses the `meta` table of the database it already opens. */
81
+ export interface MapsBudgetStore {
82
+ read: (key: string) => string | null;
83
+ write: (key: string, value: string) => void;
84
+ }
85
+ /** The two periods a call is measured against. They answer different questions:
86
+ * a month says whether the free tier is safe, a day says whether something is
87
+ * looping right now — and they come back at different times, which is the
88
+ * reason an app is told WHICH one refused. */
89
+ export type BudgetPeriod = "month" | "day";
90
+ export type BudgetVerdict =
91
+ /** Counted against BOTH periods. The caller may make exactly the request it
92
+ * asked for. `used`/`ceiling` are the MONTH's — the figure a health surface
93
+ * has always shown; `usage()` reports the day beside it. */
94
+ {
95
+ allowed: true;
96
+ used: number;
97
+ ceiling: number;
98
+ }
99
+ /**
100
+ * 🔴 Refused. The caller must DEGRADE LOUDLY — never skip silently, and never
101
+ * make the call anyway. `reason` is viewer-facing; `fix` is for whoever has to
102
+ * do something about it, and is empty when the app has nothing to add.
103
+ *
104
+ * `used`, `ceiling`, `scope` and `period` all describe the ONE period that
105
+ * refused, so a caller can say when the API comes back without re-deriving it.
106
+ */
107
+ | {
108
+ allowed: false;
109
+ used: number;
110
+ ceiling: number;
111
+ scope: BudgetPeriod;
112
+ period: string;
113
+ reason: string;
114
+ fix: string;
115
+ };
116
+ /** What an app is told when it must explain a refusal. */
117
+ export interface RefusalContext<Api extends string> {
118
+ api: Api;
119
+ /** Calls already spent in the period that refused, or that period's ceiling
120
+ * when the counter is unreadable — never `Infinity`, so it is always
121
+ * printable. */
122
+ used: number;
123
+ /** The ceiling of the period that refused — the MONTH's ceiling for a monthly
124
+ * refusal, a tenth of it for a daily one. */
125
+ ceiling: number;
126
+ /** Which ceiling refused. 🔴 An app that words both cases the same is telling
127
+ * a viewer the map is off for a month when it is off until midnight. */
128
+ scope: BudgetPeriod;
129
+ /** The period that refused: `YYYY-MM` for a month, `YYYY-MM-DD` for a day. */
130
+ period: string;
131
+ /** True when the counter could not be read and was failed safe. The wording
132
+ * for that case is genuinely different: nothing was necessarily spent. */
133
+ corrupted: boolean;
134
+ }
135
+ export interface MapsBudget<Api extends string> {
136
+ /**
137
+ * Check AND count in one step, immediately before the request.
138
+ *
139
+ * 🔴 The order is the whole point: counting after the call would count
140
+ * correctly and protect nothing. `calls` is for a BATCH endpoint (family's
141
+ * elevation lookup sends 100 points in one request) and a batch that would
142
+ * cross EITHER line is refused whole rather than truncated — half an answer
143
+ * drawn as a complete one is worse than a labeled absence.
144
+ */
145
+ consume(api: Api, calls?: number): BudgetVerdict;
146
+ /** Read-only, for a health surface. Both periods, because the one that will
147
+ * actually refuse the next call is usually the day, and a surface showing
148
+ * only "12 of 3000" would read as healthy while the API is off until
149
+ * midnight. */
150
+ usage(api: Api): {
151
+ api: Api;
152
+ used: number;
153
+ ceiling: number;
154
+ period: string;
155
+ day: {
156
+ used: number;
157
+ ceiling: number;
158
+ period: string;
159
+ };
160
+ };
161
+ }
162
+ /** `YYYY-MM` in local time — the period a monthly allowance is measured over. */
163
+ export declare const monthKey: (at: number) => string;
164
+ /** `YYYY-MM-DD` in local time — the period the daily sub-ceiling is measured
165
+ * over. Local, like `monthKey`, so "today" means the owner's today. */
166
+ export declare const dayKey: (at: number) => string;
167
+ /** How much of a month's allowance one day may take. A tenth: expected use is a
168
+ * few dozen calls a day against ceilings sized ~100× that, so a tenth still
169
+ * leaves roughly 10× headroom on the busiest API while stopping a runaway in
170
+ * hours instead of in a billing period. */
171
+ export declare const DAILY_SHARE_OF_MONTH = 10;
172
+ /**
173
+ * The daily sub-ceiling, DERIVED — never a second table.
174
+ *
175
+ * Rounded UP, so a ceiling of 1 stays reachable (a tenth of it rounded down
176
+ * would be zero, i.e. an API switched off by arithmetic) and every API keeps at
177
+ * least one call a day.
178
+ */
179
+ export declare const dailyCeiling: (monthlyCeiling: number) => number;
180
+ export interface MapsBudgetOptions<Api extends string> {
181
+ store: MapsBudgetStore;
182
+ /** The ceiling per API. Every API the app may call must appear, so adding one
183
+ * is a deliberate act — a new line in the owner's bill and a new row here. */
184
+ ceilings: Record<Api, number>;
185
+ /** How this app words a refusal. Supplied by the app rather than templated
186
+ * here because the audience differs: family's reaches a relative looking at a
187
+ * map, orch's reaches the owner's notification bell. */
188
+ refusal: (context: RefusalContext<Api>) => {
189
+ reason: string;
190
+ fix?: string;
191
+ };
192
+ now?: () => number;
193
+ }
194
+ export declare function createMapsBudget<Api extends string>(options: MapsBudgetOptions<Api>): MapsBudget<Api>;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A HARD, persisted call ceiling for Google Maps Platform — monthly AND daily.
3
+ *
4
+ * ── Why this is a package and not a helper in one app ───────────────────────
5
+ * Two apps had their own copy of it — `apps/orch` (leave-by times) and
6
+ * `apps/family` (the atlas) — and two consumers is exactly the threshold the
7
+ * placement law names. The reason to promote rather than to leave them is
8
+ * sharper than tidiness: **the guarantee is that the ceiling and the network
9
+ * call cannot be separated**, and a rule living in two files that can drift is a
10
+ * guarantee with two chances to stop being one. A THIRD copy was the real risk.
11
+ *
12
+ * ── 🔴 `cursedbelt-server/maps-budget`, since 4.19.0 (task 345) ──────────────
13
+ * It was copied again in THIS generation, under two file names —
14
+ * `apps/family/src/kit/mapsBudget.ts` and `apps/station/src/kit/mapsPlatform.ts`,
15
+ * 13,408 identical bytes that no basename census could see. It lives here now,
16
+ * with its suite, as a leaf that imports nothing: an app injects its own
17
+ * {@link MapsBudgetStore}, so neither app can reach the other's ledger, and the
18
+ * generic `Api` parameter lets each name its own API set without a shared enum.
19
+ * It is in the SERVER tier rather than `cursedbelt-core` because a spend ceiling
20
+ * only means anything where the key is — never in a browser bundle.
21
+ *
22
+ * ── The owner's constraint, verbatim (2026-08-08) ───────────────────────────
23
+ * *"Note I don't want to allow spending with the apis so we have to enforce the
24
+ * limits for the free tiers if applicable."*
25
+ *
26
+ * Maps Platform is unlike the free Google APIs in three ways that together make
27
+ * a client-side ceiling the only real protection:
28
+ *
29
+ * 1. It REQUIRES a billing account. A runaway loop on Gmail or Calendar costs
30
+ * nothing; here it bills.
31
+ * 2. The free allowance is 10,000 events/month per Essentials API. Past that,
32
+ * calls cost money rather than failing.
33
+ * 3. 🔴 **A Cloud BUDGET DOES NOT CAP USAGE — it only alerts.** The only
34
+ * server-side hard stop is a per-API QUOTA in Cloud Console, and that step
35
+ * is the owner's. This is ours.
36
+ *
37
+ * ── 🔴 TWO periods, because a month is not a shape a runaway respects ───────
38
+ * A monthly cap can be burned inside a single day. A loop that starts on the
39
+ * 2nd spends the whole allowance by the 3rd and leaves the API dead for the
40
+ * other 29 — which is the outcome the ceiling exists to PREVENT, arriving by
41
+ * way of the ceiling itself. So every call is measured against both:
42
+ *
43
+ * · the **month**, which protects the free tier and therefore the bill;
44
+ * · the **day**, `dailyCeiling()` of the month's, which catches the loop in
45
+ * hours rather than in a billing period.
46
+ *
47
+ * The daily number is DERIVED from the monthly one and never written down
48
+ * twice. Two tables drift, and this module exists because two copies of this
49
+ * rule already did.
50
+ *
51
+ * Both have to pass, and a refusal by either counts nothing. Expected real
52
+ * volume across ALL APIs is a few dozen calls a day — about 1% of the free
53
+ * allowance — so a tenth of the month still leaves roughly 10× headroom on the
54
+ * busiest single API. A ceiling being hit still means something is broken.
55
+ *
56
+ * ── Two properties that are not negotiable ─────────────────────────────────
57
+ * **PERSISTED, never in memory** — BOTH counters. An in-memory counter resets
58
+ * on every restart, and a crash-loop restarting hourly would reset it hourly —
59
+ * turning the one safeguard into a formality precisely when something is wrong.
60
+ * A daily counter is the one a restart-loop would defeat most cheaply, so it is
61
+ * persisted the same way. Each key embeds its own period, so a new month or a
62
+ * new day starts at zero with no scheduled reset to fail and no "the machine was
63
+ * off on the 1st" hole.
64
+ *
65
+ * **A corrupted counter fails SAFE**, treated as exhausted rather than as zero —
66
+ * the daily one exactly as the monthly one, deliberately. The wrong direction
67
+ * here spends the owner's money, and two fail-safes with different opinions in
68
+ * one module would be worse than either choice.
69
+ *
70
+ * ── What is deliberately NOT here ──────────────────────────────────────────
71
+ * The typed API callers. `apps/family` talks to eight Maps APIs plus Street
72
+ * View; `apps/orch` talks to Routes and Weather. They share no call site, only
73
+ * this rule — and hoisting nine wrappers into a package to give two apps one
74
+ * counter would be the shared-code version of the same mistake this package
75
+ * exists to fix. Each app keeps its own ONE DOOR (`maps.ts` in family, pinned by
76
+ * a test that scans for Google hosts outside it) and both doors consume this.
77
+ */
78
+ /** `YYYY-MM` in local time — the period a monthly allowance is measured over. */
79
+ export const monthKey = (at) => {
80
+ const d = new Date(at);
81
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
82
+ };
83
+ /** `YYYY-MM-DD` in local time — the period the daily sub-ceiling is measured
84
+ * over. Local, like `monthKey`, so "today" means the owner's today. */
85
+ export const dayKey = (at) => {
86
+ const d = new Date(at);
87
+ return `${monthKey(at)}-${String(d.getDate()).padStart(2, "0")}`;
88
+ };
89
+ /** How much of a month's allowance one day may take. A tenth: expected use is a
90
+ * few dozen calls a day against ceilings sized ~100× that, so a tenth still
91
+ * leaves roughly 10× headroom on the busiest API while stopping a runaway in
92
+ * hours instead of in a billing period. */
93
+ export const DAILY_SHARE_OF_MONTH = 10;
94
+ /**
95
+ * The daily sub-ceiling, DERIVED — never a second table.
96
+ *
97
+ * Rounded UP, so a ceiling of 1 stays reachable (a tenth of it rounded down
98
+ * would be zero, i.e. an API switched off by arithmetic) and every API keeps at
99
+ * least one call a day.
100
+ */
101
+ export const dailyCeiling = (monthlyCeiling) => Math.ceil(monthlyCeiling / DAILY_SHARE_OF_MONTH);
102
+ export function createMapsBudget(options) {
103
+ const now = options.now ?? Date.now;
104
+ /**
105
+ * 🔴 The monthly key is UNCHANGED (`maps-budget.<api>.2026-09`) — live stores
106
+ * hold counts under it. The daily one is the same shape with the day on the
107
+ * end (`…2026-09-11`), so the two can never collide and no migration is
108
+ * needed: the first call of a new day simply starts a new counter at zero.
109
+ *
110
+ * The store is read/write only — deliberately, so an app can back it with any
111
+ * KV it already has — which means nothing prunes yesterday's keys. That is
112
+ * a few hundred bytes per API per year, and cheaper than an interface that
113
+ * can delete rows in the owner's database.
114
+ */
115
+ const key = (api, period) => `maps-budget.${api}.${period}`;
116
+ const readCount = (api, period) => {
117
+ const raw = Number(options.store.read(key(api, period)) ?? 0);
118
+ // 🔴 Fails SAFE — see the header. Exhausted, never zero. The daily counter
119
+ // takes the same reading for the same reason; two fail-safes with different
120
+ // opinions would be worse than either.
121
+ return Number.isFinite(raw) && raw >= 0 ? raw : Number.POSITIVE_INFINITY;
122
+ };
123
+ /** Both lines for `api` at this instant — the month, then the day. */
124
+ const linesFor = (api, at) => {
125
+ const monthly = options.ceilings[api];
126
+ return [
127
+ { scope: "month", period: monthKey(at), ceiling: monthly },
128
+ { scope: "day", period: dayKey(at), ceiling: dailyCeiling(monthly) },
129
+ ];
130
+ };
131
+ return {
132
+ consume(api, calls = 1) {
133
+ const at = now();
134
+ const [month, day] = linesFor(api, at);
135
+ const monthUsed = readCount(api, month.period);
136
+ const dayUsed = readCount(api, day.period);
137
+ /**
138
+ * 🔴 The MONTH is tested first, and the order is not cosmetic: it decides
139
+ * what a viewer is told when both are spent, and only one answer is true
140
+ * then. "Off until midnight" is a promise the budget cannot keep once the
141
+ * month is gone; "off until the month ends" is right in both cases.
142
+ */
143
+ const refused = monthUsed + calls > month.ceiling
144
+ ? { line: month, used: monthUsed }
145
+ : dayUsed + calls > day.ceiling
146
+ ? { line: day, used: dayUsed }
147
+ : null;
148
+ if (refused) {
149
+ const corrupted = !Number.isFinite(refused.used);
150
+ const used = corrupted ? refused.line.ceiling : refused.used;
151
+ const spoken = options.refusal({
152
+ api,
153
+ used,
154
+ ceiling: refused.line.ceiling,
155
+ scope: refused.line.scope,
156
+ period: refused.line.period,
157
+ corrupted,
158
+ });
159
+ return {
160
+ allowed: false,
161
+ used,
162
+ ceiling: refused.line.ceiling,
163
+ scope: refused.line.scope,
164
+ period: refused.line.period,
165
+ reason: spoken.reason,
166
+ fix: spoken.fix ?? "",
167
+ };
168
+ }
169
+ // Both fit, so both are counted. A refusal above wrote nothing at all —
170
+ // a call that did not happen must not be charged to either period.
171
+ options.store.write(key(api, month.period), String(monthUsed + calls));
172
+ options.store.write(key(api, day.period), String(dayUsed + calls));
173
+ return { allowed: true, used: monthUsed + calls, ceiling: month.ceiling };
174
+ },
175
+ usage(api) {
176
+ const [month, day] = linesFor(api, now());
177
+ // A corrupted counter reports its ceiling rather than `Infinity`, so a
178
+ // health surface prints a number and reads as exhausted — which is how
179
+ // `consume` is about to treat it.
180
+ const spent = (line) => {
181
+ const used = readCount(api, line.period);
182
+ return Number.isFinite(used) ? used : line.ceiling;
183
+ };
184
+ return {
185
+ api,
186
+ used: spent(month),
187
+ ceiling: month.ceiling,
188
+ period: month.period,
189
+ day: { used: spent(day), ceiling: day.ceiling, period: day.period },
190
+ };
191
+ },
192
+ };
193
+ }