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.
- package/dist/server/activity/index.d.ts +2 -1
- package/dist/server/activity/index.js +2 -1
- package/dist/server/auth/passwordCost.d.ts +21 -0
- package/dist/server/auth/passwordCost.js +80 -0
- package/dist/server/bench/index.d.ts +1 -0
- package/dist/server/bench/index.js +1 -0
- package/dist/server/bench/tail.d.ts +110 -0
- package/dist/server/bench/tail.js +182 -0
- package/dist/server/d1/index.d.ts +1 -2
- package/dist/server/d1/index.js +9 -9
- package/dist/server/d1/pullD1.js +16 -3
- package/dist/server/engagement/api.d.ts +71 -0
- package/dist/server/engagement/api.js +84 -0
- package/dist/server/engagement/env.d.ts +18 -0
- package/dist/server/engagement/env.js +52 -0
- package/dist/server/engagement/index.d.ts +55 -0
- package/dist/server/engagement/index.js +55 -0
- package/dist/server/engagement/places.d.ts +22 -0
- package/dist/server/engagement/places.js +63 -0
- package/dist/server/engagement/policy.d.ts +168 -0
- package/dist/server/engagement/policy.js +202 -0
- package/dist/server/engagement/store.d.ts +92 -0
- package/dist/server/engagement/store.js +223 -0
- package/dist/server/engagement/summary.d.ts +102 -0
- package/dist/server/engagement/summary.js +127 -0
- package/dist/server/engagement/types.d.ts +42 -0
- package/dist/server/engagement/types.js +12 -0
- package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
- package/dist/server/maps-budget/mapsBudget.js +193 -0
- package/dist/server/satellite/config.d.ts +173 -0
- package/dist/server/satellite/config.js +259 -0
- package/dist/server/satellite/door.d.ts +112 -0
- package/dist/server/satellite/door.js +149 -0
- package/dist/server/storage/binaryStore.d.ts +18 -0
- package/dist/server/storage/binaryStore.js +32 -1
- package/dist/server/storage/derivatives.d.ts +253 -0
- package/dist/server/storage/derivatives.js +266 -0
- package/dist/server/storage/uploadSession.d.ts +75 -0
- package/dist/server/storage/uploadSession.js +74 -0
- package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
- package/docs/activity.md +43 -0
- package/docs/engagement.md +47 -0
- package/docs/notifications.md +43 -0
- package/docs/retention.md +81 -0
- package/docs/skipped-tests.md +19 -0
- package/package.json +46 -9
- package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
- package/src/leafSubpathsImportNothing.spec.ts +49 -0
- package/src/server/activity/index.ts +2 -1
- package/src/server/auth/passwordCost.spec.ts +42 -0
- package/src/server/auth/passwordCost.ts +87 -0
- package/src/server/bench/index.ts +13 -0
- package/src/server/bench/tail.spec.ts +126 -0
- package/src/server/bench/tail.ts +237 -0
- package/src/server/d1/index.ts +9 -9
- package/src/server/d1/pullD1.spec.ts +20 -0
- package/src/server/d1/pullD1.ts +18 -2
- package/src/server/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- package/src/server/d1/kysely.ts +0 -169
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REAL reading: Worker CPU-ms off a tail worker's trace events, fed into the same recorder
|
|
3
|
+
* and the same assertion the local proxy uses.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this shape, and not a clock
|
|
6
|
+
*
|
|
7
|
+
* {@link workerCpuClock} is a clock — `start()` then read again — and the isolate never hands a
|
|
8
|
+
* handler its own CPU time, so there is nothing for it to read inside a request. What Cloudflare
|
|
9
|
+
* DOES publish is one `cpuTime` per invocation on the trace event a TAIL worker receives (the
|
|
10
|
+
* same field `wrangler tail --format json` prints — `apps/binary-server/scripts/edgeTail.ts`
|
|
11
|
+
* already reads it there). So the real number arrives AFTER the request, per request, and the
|
|
12
|
+
* honest API is to record it then: {@link recordTailTraces} turns trace events into samples on a
|
|
13
|
+
* recorder built with {@link tailCpuClock}, which stamps `proxy: false`.
|
|
14
|
+
*
|
|
15
|
+
* Two ways to get the events, and a consumer needs neither code nor a new Worker for the first:
|
|
16
|
+
*
|
|
17
|
+
* · **`wrangler tail <worker> --format json`** during a bench or a smoke against the live
|
|
18
|
+
* Worker — one JSON trace per line, parsed with {@link parseTailLines}.
|
|
19
|
+
* · **a Tail Worker** (`tail_consumers` in the producer's wrangler config) whose `tail(events)`
|
|
20
|
+
* hands the array straight to {@link recordTailTraces}.
|
|
21
|
+
*
|
|
22
|
+
* 🔴 The degenerate guard still applies, deliberately. `cpuTime` is whole milliseconds, so a
|
|
23
|
+
* route that genuinely costs under 1 ms reads 0 — and a report where EVERY reading is 0 is still
|
|
24
|
+
* refused, because that is indistinguishable from a reader that is not reading. One real route
|
|
25
|
+
* above a millisecond anywhere in the window clears it; `allowZeroCpu` is not the fix.
|
|
26
|
+
*
|
|
27
|
+
* ## The traffic half
|
|
28
|
+
*
|
|
29
|
+
* {@link tailTrafficRate} is requests per day over the window the events span — the number
|
|
30
|
+
* `deriveCpuBudgetMs({ requestsPerDay })` needs to re-derive the default from THIS generation's
|
|
31
|
+
* Worker traffic (tasks 342/377) instead of the retired generation's corpus.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { CpuClock } from './cpuClock.js';
|
|
35
|
+
import type { CpuRecorder } from './recorder.js';
|
|
36
|
+
|
|
37
|
+
/** The fields of a Cloudflare `TraceItem` this reads — structural, so no workers-types import. */
|
|
38
|
+
export interface TailTraceLike {
|
|
39
|
+
/** CPU-ms of the whole invocation, as billed. Absent on runtimes that do not report it. */
|
|
40
|
+
cpuTime?: number | null;
|
|
41
|
+
wallTime?: number | null;
|
|
42
|
+
/** Epoch ms. */
|
|
43
|
+
eventTimestamp?: number | null;
|
|
44
|
+
outcome?: string | null;
|
|
45
|
+
scriptName?: string | null;
|
|
46
|
+
/** A fetch invocation carries `request`; a cron or queue invocation does not. */
|
|
47
|
+
event?: { request?: { url?: string; method?: string } | null } | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Provenance printed on every report built from tail events. */
|
|
51
|
+
export const TAIL_CPU_SOURCE = 'tail-worker cpuTime';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The clock for a recorder that is FED rather than started. `proxy: false`: this is the quantity
|
|
55
|
+
* Cloudflare bills. Its `start()` throws — a tail recorder mounted as `cpuBudget()` middleware is
|
|
56
|
+
* a wiring mistake, and a span that silently measured nothing is the lie this module refuses.
|
|
57
|
+
*/
|
|
58
|
+
export function tailCpuClock(source = TAIL_CPU_SOURCE): CpuClock {
|
|
59
|
+
return {
|
|
60
|
+
source,
|
|
61
|
+
proxy: false,
|
|
62
|
+
available: true,
|
|
63
|
+
start() {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'tailCpuClock: a tail recorder is FED by recordTailTraces(), never started — the isolate ' +
|
|
66
|
+
'does not hand a handler its own CPU time. Mount cpuBudget() with processCpuClock() ' +
|
|
67
|
+
'for the local proxy, and feed this recorder from a tail.',
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A route key's pattern half: `'GET /api/items/:id'` → `/api/items/:id`, with its method. */
|
|
74
|
+
function splitKey(key: string): { method: string | null; pattern: string } {
|
|
75
|
+
const m = /^([A-Z]+)\s+(\S+)$/.exec(key.trim());
|
|
76
|
+
return m ? { method: m[1] ?? null, pattern: m[2] ?? key } : { method: null, pattern: key.trim() };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hono-style pattern → anchored regex. `:param` (with an optional `{re}`) is one segment, `*` is the rest. */
|
|
80
|
+
function patternRegex(pattern: string): RegExp {
|
|
81
|
+
let out = '';
|
|
82
|
+
for (let i = 0; i < pattern.length; ) {
|
|
83
|
+
const rest = pattern.slice(i);
|
|
84
|
+
const param = /^:[A-Za-z0-9_]+(\{[^}]*\})?\??/.exec(rest);
|
|
85
|
+
if (param) {
|
|
86
|
+
out += '[^/]+';
|
|
87
|
+
i += param[0].length;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const ch = pattern[i] as string;
|
|
91
|
+
out += ch === '*' ? '.*' : ch.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
return new RegExp(`^${out}$`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How literal a pattern is — the more literal characters, the earlier it is tried. */
|
|
98
|
+
const specificity = (pattern: string): number =>
|
|
99
|
+
pattern.replace(/:[A-Za-z0-9_]+(\{[^}]*\})?\??/g, '').replace(/\*/g, '').length -
|
|
100
|
+
(pattern.includes('*') ? 1000 : 0);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A `(method, path) → route label` matcher over declared route keys — the recorder's own
|
|
104
|
+
* `config.routes`, by default — so an app gets tail attribution with no code of its own. The
|
|
105
|
+
* most literal pattern wins, and a method-specific key beats an any-method one.
|
|
106
|
+
*/
|
|
107
|
+
export function routeMatcher(keys: readonly string[]): (method: string, path: string) => string | null {
|
|
108
|
+
const compiled = keys
|
|
109
|
+
.map((key) => ({ ...splitKey(key), key }))
|
|
110
|
+
.map((k) => ({ ...k, re: patternRegex(k.pattern), score: specificity(k.pattern) + (k.method ? 0.5 : 0) }))
|
|
111
|
+
.sort((a, b) => b.score - a.score);
|
|
112
|
+
return (method, path) => {
|
|
113
|
+
for (const k of compiled) {
|
|
114
|
+
if (k.method && k.method !== method.toUpperCase()) continue;
|
|
115
|
+
if (k.re.test(path)) return k.pattern;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The label traffic that matched no declared route is recorded under — so `requireDeclared` names it. */
|
|
122
|
+
export const UNMATCHED_ROUTE = '(no declared route)';
|
|
123
|
+
|
|
124
|
+
export interface RecordTailTracesOpts {
|
|
125
|
+
/** Map a request to its route label. Default: {@link routeMatcher} over the recorder's declared routes. */
|
|
126
|
+
route?: (method: string, path: string) => string | null;
|
|
127
|
+
/** Only this Worker's invocations — a tail consumer may receive several producers. */
|
|
128
|
+
scriptName?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface TailRecordResult {
|
|
132
|
+
recorded: number;
|
|
133
|
+
/** Invocations with no request (cron, queue) — CPU, but no route to charge it to. */
|
|
134
|
+
notFetch: number;
|
|
135
|
+
/** Fetch invocations whose trace carried no finite `cpuTime`. */
|
|
136
|
+
noCpuTime: number;
|
|
137
|
+
/** Recorded under {@link UNMATCHED_ROUTE}; up to ten distinct paths, so the gap is nameable. */
|
|
138
|
+
unmatched: number;
|
|
139
|
+
unmatchedPaths: string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Charge each fetch invocation's billed `cpuTime` to its route on `recorder`.
|
|
144
|
+
*
|
|
145
|
+
* 🔴 A trace with no finite `cpuTime` is NOT a zero — it is skipped and counted, so a runtime
|
|
146
|
+
* that stopped reporting the field reads as "nothing recorded" (and the assertion's `no-samples`
|
|
147
|
+
* guard reds it) rather than as a confident route that costs nothing.
|
|
148
|
+
*/
|
|
149
|
+
export function recordTailTraces(
|
|
150
|
+
recorder: CpuRecorder,
|
|
151
|
+
traces: readonly TailTraceLike[],
|
|
152
|
+
opts: RecordTailTracesOpts = {},
|
|
153
|
+
): TailRecordResult {
|
|
154
|
+
if (recorder.clock.proxy) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`recordTailTraces: the recorder's clock '${recorder.clock.source}' is a PROXY. Tail cpuTime is the ` +
|
|
157
|
+
'billed quantity — build this recorder with tailCpuClock(), or its report will call real numbers a proxy.',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
const route = opts.route ?? routeMatcher(Object.keys(recorder.config.routes ?? {}));
|
|
161
|
+
const result: TailRecordResult = { recorded: 0, notFetch: 0, noCpuTime: 0, unmatched: 0, unmatchedPaths: [] };
|
|
162
|
+
for (const trace of traces) {
|
|
163
|
+
if (opts.scriptName && trace.scriptName && trace.scriptName !== opts.scriptName) continue;
|
|
164
|
+
const request = trace.event?.request;
|
|
165
|
+
if (!request?.url) {
|
|
166
|
+
result.notFetch += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const cpu = trace.cpuTime;
|
|
170
|
+
if (typeof cpu !== 'number' || !Number.isFinite(cpu)) {
|
|
171
|
+
result.noCpuTime += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
175
|
+
let path: string;
|
|
176
|
+
try {
|
|
177
|
+
path = new URL(request.url).pathname;
|
|
178
|
+
} catch {
|
|
179
|
+
path = request.url;
|
|
180
|
+
}
|
|
181
|
+
const label = route(method, path);
|
|
182
|
+
if (label === null) {
|
|
183
|
+
result.unmatched += 1;
|
|
184
|
+
if (result.unmatchedPaths.length < 10 && !result.unmatchedPaths.includes(`${method} ${path}`)) {
|
|
185
|
+
result.unmatchedPaths.push(`${method} ${path}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
recorder.record(method, label ?? UNMATCHED_ROUTE, cpu);
|
|
189
|
+
result.recorded += 1;
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* `wrangler tail --format json` output → trace events. Tolerates the banner lines wrangler
|
|
196
|
+
* prints and blank lines; a line that is not a JSON object is skipped, never guessed at.
|
|
197
|
+
*/
|
|
198
|
+
export function parseTailLines(text: string): TailTraceLike[] {
|
|
199
|
+
const out: TailTraceLike[] = [];
|
|
200
|
+
for (const line of text.split('\n')) {
|
|
201
|
+
const trimmed = line.trim();
|
|
202
|
+
if (!trimmed.startsWith('{')) continue;
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
205
|
+
if (parsed && typeof parsed === 'object') out.push(parsed as TailTraceLike);
|
|
206
|
+
} catch {
|
|
207
|
+
// a partial line from an interrupted tail — not a trace
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface TailTrafficRate {
|
|
214
|
+
/** Fetch invocations in the window. */
|
|
215
|
+
requests: number;
|
|
216
|
+
/** First to last `eventTimestamp`, ms. */
|
|
217
|
+
spanMs: number;
|
|
218
|
+
/** `requests` scaled to a day — `NaN` when the window is too short to scale honestly. */
|
|
219
|
+
requestsPerDay: number;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Requests per day over the window the traces span — what `deriveCpuBudgetMs({ requestsPerDay })`
|
|
224
|
+
* takes. `NaN` below an hour of window: scaling ten minutes of traffic to a day is a guess with a
|
|
225
|
+
* decimal point, and a budget derived from it would be quoted as a measurement.
|
|
226
|
+
*/
|
|
227
|
+
export function tailTrafficRate(traces: readonly TailTraceLike[], minSpanMs = 60 * 60 * 1000): TailTrafficRate {
|
|
228
|
+
const stamps = traces
|
|
229
|
+
.filter((t) => t.event?.request?.url)
|
|
230
|
+
.map((t) => t.eventTimestamp)
|
|
231
|
+
.filter((s): s is number => typeof s === 'number' && Number.isFinite(s))
|
|
232
|
+
.sort((a, b) => a - b);
|
|
233
|
+
const requests = stamps.length;
|
|
234
|
+
const spanMs = requests > 1 ? (stamps[requests - 1] as number) - (stamps[0] as number) : 0;
|
|
235
|
+
const requestsPerDay = spanMs >= minSpanMs ? (requests / spanMs) * 86_400_000 : Number.NaN;
|
|
236
|
+
return { requests, spanMs, requestsPerDay };
|
|
237
|
+
}
|
package/src/server/d1/index.ts
CHANGED
|
@@ -7,11 +7,10 @@
|
|
|
7
7
|
* local driver is deliberately as strict as D1 rather than as lenient as Bun.
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
|
-
* import { createLocalD1, createRemoteD1
|
|
10
|
+
* import { createLocalD1, createRemoteD1 } from 'cursedbelt-server/d1';
|
|
11
11
|
*
|
|
12
12
|
* const db = env.DB ? createRemoteD1(env.DB) : createLocalD1(sqlite);
|
|
13
13
|
* const row = await db.prepare('SELECT * FROM people WHERE id = ?').bind(id).first();
|
|
14
|
-
* const ky = createD1Kysely(db);
|
|
15
14
|
* ```
|
|
16
15
|
*/
|
|
17
16
|
|
|
@@ -32,13 +31,14 @@ export {
|
|
|
32
31
|
// as the note below, one step wider: a barrel must reach neither an optional peer nor a
|
|
33
32
|
// runtime the target platform lacks. `barrelsReachNoOptionalPeer.spec.ts` checks both.
|
|
34
33
|
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
34
|
+
// `./kysely` — a Kysely dialect over this seam, split out as its own `d1/kysely` subpath on
|
|
35
|
+
// 2026-09-18 because it dragged the OPTIONAL `kysely` peer into every `import '…/d1'` — was
|
|
36
|
+
// REMOVED in 4.19.0 (task 472). Measured 2026-09-23: no file under the generation imported
|
|
37
|
+
// it, no app depends on `kysely` at all, and every ported Worker (patterns, collections,
|
|
38
|
+
// music, vault) reached D1 through raw statements on this seam. A dialect nobody wants is
|
|
39
|
+
// surface with a peer attached; if a ported app ever wants a query builder, it comes back
|
|
40
|
+
// with that app as its first consumer, and `barrelsReachNoOptionalPeer.spec.ts` still keeps
|
|
41
|
+
// it out of this barrel.
|
|
42
42
|
export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
|
|
43
43
|
export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
|
|
44
44
|
export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
@@ -172,6 +172,26 @@ describe('pulling D1 down — table by table, because a whole-database export re
|
|
|
172
172
|
).rejects.toThrow(/lists no `tracks` table/);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test('an export that says success and writes NO file is asked again — and refuses if it never does', async () => {
|
|
176
|
+
const dir = scratch();
|
|
177
|
+
let flaky = 1;
|
|
178
|
+
const run: Wrangler = (args) => {
|
|
179
|
+
if (args[1] === 'execute') return { code: 0, stdout: JSON.stringify([{ results: MASTER }]), stderr: '' };
|
|
180
|
+
const table = args[args.indexOf('--table') + 1] as string;
|
|
181
|
+
const out = args[args.indexOf('--output') + 1] as string;
|
|
182
|
+
// `meta` "succeeds" once without writing — measured on collections, 2026-09-23.
|
|
183
|
+
if (table === 'meta' && flaky-- > 0) return { code: 0, stdout: 'Downloaded successfully', stderr: '' };
|
|
184
|
+
writeFileSync(out, table === 'items' ? "INSERT INTO items (id) VALUES ('i1');" : '');
|
|
185
|
+
return { code: 0, stdout: '', stderr: '' };
|
|
186
|
+
};
|
|
187
|
+
await pull(dir, run);
|
|
188
|
+
const never: Wrangler = (args) =>
|
|
189
|
+
args[1] === 'execute'
|
|
190
|
+
? { code: 0, stdout: JSON.stringify([{ results: MASTER }]), stderr: '' }
|
|
191
|
+
: { code: 0, stdout: 'Downloaded successfully', stderr: '' };
|
|
192
|
+
await expect(pull(scratch(), never)).rejects.toThrow(/reported success 3 times and wrote no file/);
|
|
193
|
+
});
|
|
194
|
+
|
|
175
195
|
test('🔴 one table failing to export THROWS — never a snapshot missing that table', async () => {
|
|
176
196
|
const dir = scratch();
|
|
177
197
|
const { run } = fake((table) => (table === 'meta' ? null : "INSERT INTO items (id) VALUES ('x');"));
|
package/src/server/d1/pullD1.ts
CHANGED
|
@@ -97,6 +97,9 @@ export async function servedRuntime(
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** How many times one table's export is asked for when wrangler says success and writes nothing. */
|
|
101
|
+
const EXPORT_ATTEMPTS = 3;
|
|
102
|
+
|
|
100
103
|
/** One `wrangler` invocation, as {@link pullD1ToSqlite} needs it. Injected so a spec runs none. */
|
|
101
104
|
export type Wrangler = (args: string[]) => { code: number; stdout: string; stderr: string };
|
|
102
105
|
|
|
@@ -160,11 +163,24 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
|
|
|
160
163
|
let sql = '';
|
|
161
164
|
for (const table of tables) {
|
|
162
165
|
const dump = `${destination}.${table}.sql`;
|
|
163
|
-
|
|
166
|
+
// 🔴 `wrangler d1 export` has been measured answering SUCCESS and writing no file —
|
|
167
|
+
// collections' nightly of 2026-09-23T15:35Z refused on `derivative_retries`, and the same
|
|
168
|
+
// command wrote the file (32 bytes, an empty table) an hour later. So a missing file is
|
|
169
|
+
// asked for again, twice, before it is a refusal; a failure that persists still refuses,
|
|
170
|
+
// because a snapshot missing a table is not a backup.
|
|
171
|
+
let exported = { code: 1, stdout: '', stderr: 'not attempted' };
|
|
172
|
+
for (let attempt = 1; attempt <= EXPORT_ATTEMPTS; attempt++) {
|
|
173
|
+
exported = wrangler(['d1', 'export', database, '--remote', '--env=', '--table', table, '--no-schema', '--output', dump, '-y']);
|
|
174
|
+
if (exported.code !== 0 || existsSync(dump)) break;
|
|
175
|
+
}
|
|
164
176
|
if (exported.code !== 0) {
|
|
165
177
|
throw new Error(`wrangler d1 export --table ${table} failed: ${exported.stderr.trim() || exported.stdout.trim()}`);
|
|
166
178
|
}
|
|
167
|
-
if (!existsSync(dump))
|
|
179
|
+
if (!existsSync(dump)) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`wrangler d1 export --table ${table} reported success ${EXPORT_ATTEMPTS} times and wrote no file at ${dump}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
168
184
|
sql += `${readFileSync(dump, 'utf8')}\n`;
|
|
169
185
|
}
|
|
170
186
|
if (!/INSERT INTO/i.test(sql)) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one door engagement needs, and the in-process read of what it recorded.
|
|
3
|
+
*
|
|
4
|
+
* POST /api/engagement/view → the beacon. SESSION-gated; the user id comes
|
|
5
|
+
* from the session, never from the body.
|
|
6
|
+
*
|
|
7
|
+
* 🔴 **There is no HTTP read surface, on purpose** (task `113`/`268`, 2026-09-22).
|
|
8
|
+
* `GET /api/admin/engagement` used to sit here behind `SATELLITE_METRICS_TOKEN` —
|
|
9
|
+
* ONE bearer shared across five production env files, so a leak from any app was
|
|
10
|
+
* a read on all of them — for a fleet console that lives in the retired
|
|
11
|
+
* generation. Nothing in this generation read it over HTTP: station's telemetry
|
|
12
|
+
* page reads each app's sqlite straight off the disk, and the analytics reader
|
|
13
|
+
* planned after it does the same with `engagement.sqlite`. Recording carries on
|
|
14
|
+
* regardless; `readAppEngagement` below is the snapshot a same-machine reader
|
|
15
|
+
* computes from the store. If an HTTP reader is ever wanted, it gets a PER-APP
|
|
16
|
+
* credential from the generation's secrets store, never a fleet-wide one.
|
|
17
|
+
*
|
|
18
|
+
* ── Why the user id is never in the body ────────────────────────────────────
|
|
19
|
+
* A beacon whose payload names its own user is an endpoint that lets anyone
|
|
20
|
+
* write anyone's history — and on a private household app that history is the
|
|
21
|
+
* data. `resolveUser` is supplied by the app's own SSO consumer and is the ONLY
|
|
22
|
+
* source of a user key; a request that resolves to nobody is a 401 and writes
|
|
23
|
+
* nothing. `engagement.test.ts` pins that a body-supplied `userKey` is ignored.
|
|
24
|
+
*
|
|
25
|
+
* ── Why the recorder is mounted AFTER the session gate ──────────────────────
|
|
26
|
+
* The opposite of the feedback-kit sync receiver, and for the mirrored reason:
|
|
27
|
+
* this door has no credential of its own and wants the gate to have run,
|
|
28
|
+
* because that is what puts the user where `resolveUser` can read them — while
|
|
29
|
+
* a sync receiver carries its own bearer and must mount BEFORE the gate. The
|
|
30
|
+
* rule and its reason: `docs/engagement.md` §2 in this package.
|
|
31
|
+
*/
|
|
32
|
+
import { Hono } from "hono";
|
|
33
|
+
import type { Context } from "hono";
|
|
34
|
+
import { OTHER_PLACE, type PlaceRef, resolvePlace } from "./policy.js";
|
|
35
|
+
import type { EngagementStore } from "./store.js";
|
|
36
|
+
import { type AppEngagement, returnCurve, summarize } from "./summary.js";
|
|
37
|
+
|
|
38
|
+
export interface EngagementApiOptions {
|
|
39
|
+
store: EngagementStore;
|
|
40
|
+
/** The app's own name. */
|
|
41
|
+
app: string;
|
|
42
|
+
/**
|
|
43
|
+
* The allowlist of place ids — the app's `routes.manifest.json` ids. Anything
|
|
44
|
+
* a client claims that is not in here records as `other`; see `policy.ts`
|
|
45
|
+
* rule 1 for why this is the whole privacy story.
|
|
46
|
+
*/
|
|
47
|
+
places: readonly PlaceRef[];
|
|
48
|
+
/** Resolve the CURRENT request's account. Null → 401, nothing recorded. */
|
|
49
|
+
resolveUser: (c: Context) => string | null;
|
|
50
|
+
/** Injectable clock, so the tests are not timing-dependent. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The recorder half — session-gated, mounted at `/api/engagement`. */
|
|
55
|
+
export function createEngagementRecorder(options: EngagementApiOptions): Hono {
|
|
56
|
+
const app = new Hono();
|
|
57
|
+
const now = options.now ?? (() => Date.now());
|
|
58
|
+
|
|
59
|
+
app.post("/view", async (c) => {
|
|
60
|
+
const userKey = options.resolveUser(c);
|
|
61
|
+
if (!userKey) return c.json({ error: "unauthenticated" }, 401);
|
|
62
|
+
|
|
63
|
+
let body: unknown;
|
|
64
|
+
try {
|
|
65
|
+
body = await c.req.json();
|
|
66
|
+
} catch {
|
|
67
|
+
return c.json({ error: "expected a json body" }, 400);
|
|
68
|
+
}
|
|
69
|
+
const claimed = (body as { place?: unknown } | null)?.place;
|
|
70
|
+
if (typeof claimed !== "string") return c.json({ error: "place must be a string" }, 400);
|
|
71
|
+
// 🔴 Resolution happens HERE, at the door, against the app's own manifest.
|
|
72
|
+
// The store does not re-check; see its `recordView` header for why one
|
|
73
|
+
// check in the right place beats two in the wrong ones.
|
|
74
|
+
const place = resolvePlace(claimed, options.places);
|
|
75
|
+
|
|
76
|
+
const fold = options.store.recordView({ userKey, place, ts: now() });
|
|
77
|
+
// Echo the RESOLVED place, not the claimed one. A client that sent
|
|
78
|
+
// something off-manifest learns that it was bucketed, which is how a
|
|
79
|
+
// missing manifest entry gets noticed instead of quietly reading as an
|
|
80
|
+
// unpopular feature.
|
|
81
|
+
return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return app;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The whole snapshot one app publishes about itself. */
|
|
88
|
+
export interface AppEngagementSnapshot extends AppEngagement {
|
|
89
|
+
/** The manifest ids this app CAN report — so a reader can show a place
|
|
90
|
+
* with zero views, which is the half of the question that is easy to lose. */
|
|
91
|
+
knownPlaces: string[];
|
|
92
|
+
returnCurve: Array<{ day: string; joined: number; returned: number }>;
|
|
93
|
+
retention: { days: number; places: number; users: number; oldestDay: string | null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function readAppEngagement(
|
|
97
|
+
store: EngagementStore,
|
|
98
|
+
app: string,
|
|
99
|
+
now: number,
|
|
100
|
+
knownPlaces: readonly PlaceRef[] = [],
|
|
101
|
+
): AppEngagementSnapshot {
|
|
102
|
+
const users = store.users();
|
|
103
|
+
const summary = summarize({
|
|
104
|
+
app,
|
|
105
|
+
now,
|
|
106
|
+
users,
|
|
107
|
+
places: store.places(),
|
|
108
|
+
// 120 days is the store's whole daily retention — asking for more would
|
|
109
|
+
// silently return less, which reads as a quiet period rather than as a
|
|
110
|
+
// window boundary.
|
|
111
|
+
days: store.days(now - 120 * 86_400_000),
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
...summary,
|
|
115
|
+
knownPlaces: knownPlaces.map((p) => p.id),
|
|
116
|
+
returnCurve: returnCurve(users, now),
|
|
117
|
+
retention: store.stats(),
|
|
118
|
+
};
|
|
119
|
+
}
|