blogwright-analytics 0.3.3

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 (131) hide show
  1. package/README.md +162 -0
  2. package/dist/adapters/duckdb-ingest.d.ts +76 -0
  3. package/dist/adapters/duckdb-ingest.js +173 -0
  4. package/dist/adapters/duckdb-query.d.ts +56 -0
  5. package/dist/adapters/duckdb-query.js +80 -0
  6. package/dist/adapters/duckdb-session.d.ts +168 -0
  7. package/dist/adapters/duckdb-session.js +330 -0
  8. package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
  9. package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
  10. package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
  11. package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
  12. package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
  13. package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
  14. package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
  15. package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
  16. package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
  17. package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
  18. package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
  19. package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
  20. package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
  21. package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
  22. package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
  23. package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
  24. package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
  25. package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
  26. package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
  27. package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
  28. package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
  29. package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
  30. package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
  31. package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
  32. package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  33. package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
  34. package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
  35. package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
  36. package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
  37. package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
  38. package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
  39. package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
  40. package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
  41. package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
  42. package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
  43. package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
  44. package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
  45. package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
  46. package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
  47. package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
  48. package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
  49. package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
  50. package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
  51. package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
  52. package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
  53. package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
  54. package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
  55. package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
  56. package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
  57. package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
  58. package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
  59. package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
  60. package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
  61. package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
  62. package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
  63. package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
  64. package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
  65. package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
  66. package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
  67. package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
  68. package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
  69. package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
  70. package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
  71. package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
  72. package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
  73. package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
  74. package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
  75. package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
  76. package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
  77. package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
  78. package/dist/app/_app/version.json +1 -0
  79. package/dist/app/index.html +82 -0
  80. package/dist/aws/clients.d.ts +70 -0
  81. package/dist/aws/clients.js +52 -0
  82. package/dist/aws/errors.d.ts +41 -0
  83. package/dist/aws/errors.js +70 -0
  84. package/dist/aws/firehose.d.ts +228 -0
  85. package/dist/aws/firehose.js +347 -0
  86. package/dist/aws/glue.d.ts +103 -0
  87. package/dist/aws/glue.js +225 -0
  88. package/dist/aws/lambda.d.ts +132 -0
  89. package/dist/aws/lambda.js +339 -0
  90. package/dist/aws/s3tables.d.ts +120 -0
  91. package/dist/aws/s3tables.js +281 -0
  92. package/dist/backfill.d.ts +100 -0
  93. package/dist/backfill.js +294 -0
  94. package/dist/commands.d.ts +124 -0
  95. package/dist/commands.js +336 -0
  96. package/dist/config.d.ts +162 -0
  97. package/dist/config.js +317 -0
  98. package/dist/fixture-ingest.d.ts +49 -0
  99. package/dist/fixture-ingest.js +43 -0
  100. package/dist/fixture-query.d.ts +39 -0
  101. package/dist/fixture-query.js +70 -0
  102. package/dist/index.d.ts +35 -0
  103. package/dist/index.js +35 -0
  104. package/dist/nodes.d.ts +404 -0
  105. package/dist/nodes.js +2708 -0
  106. package/dist/paths.d.ts +45 -0
  107. package/dist/paths.js +47 -0
  108. package/dist/plugin.d.ts +102 -0
  109. package/dist/plugin.js +248 -0
  110. package/dist/ports.d.ts +113 -0
  111. package/dist/ports.js +35 -0
  112. package/dist/queries.d.ts +301 -0
  113. package/dist/queries.js +414 -0
  114. package/dist/schema.d.ts +240 -0
  115. package/dist/schema.js +154 -0
  116. package/dist/server.d.ts +150 -0
  117. package/dist/server.js +499 -0
  118. package/dist/transform/bots.d.ts +47 -0
  119. package/dist/transform/bots.js +73 -0
  120. package/dist/transform/handler.d.ts +135 -0
  121. package/dist/transform/handler.js +177 -0
  122. package/dist/transform/map-record.d.ts +110 -0
  123. package/dist/transform/map-record.js +275 -0
  124. package/dist/transform/visitor-key.d.ts +83 -0
  125. package/dist/transform/visitor-key.js +120 -0
  126. package/dist/transform-bundle/index.mjs +21456 -0
  127. package/dist/transform-bundle/transform-manifest.json +4 -0
  128. package/dist/transform-hash.d.ts +135 -0
  129. package/dist/transform-hash.js +186 -0
  130. package/dist/write-transform-manifest.mjs +365 -0
  131. package/package.json +59 -0
@@ -0,0 +1,414 @@
1
+ /**
2
+ * The fixed set of named, parameterised queries this package answers from -
3
+ * never SQL supplied by a client. Seven of them are the dashboard's panels;
4
+ * see [the change spec's §Analytics dashboard → Local
5
+ * server](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md). The eighth,
6
+ * {@link ROW_COUNT_QUERY}, serves `analytics status`, and is here for the same
7
+ * reason the other seven are: a command reaches the table through the
8
+ * `AnalyticsQuery` port, and the port takes a name from this set, never a
9
+ * statement.
10
+ *
11
+ * **Parameterised is a shape here, not a convention.** A definition's SQL is
12
+ * built by the {@link sql} tag, whose only substitution slot accepts
13
+ * {@link SqlRelation} - a branded type whose single inhabitant is
14
+ * {@link PAGE_VIEWS}, declared in this module. Interpolating anything else is
15
+ * a compile error (`TS2345: Argument of type 'string' is not assignable to
16
+ * parameter of type 'SqlRelation'`), and a plain string cannot be assigned to
17
+ * {@link SqlText} either (`TS2322`), so a definition has nowhere to put a
18
+ * caller's value: caller values reach the statement only as `$name`
19
+ * placeholders, bound by {@link prepareQuery}. The tag is module-private, so
20
+ * no other module can mint SQL for this port at all.
21
+ *
22
+ * **The type-level block is the one holding the property; the runtime tests
23
+ * are a partial net under it, not a second copy of it.** `tsc` rejects *every*
24
+ * splice of a caller value that is not deliberately cast, and `pnpm typecheck`
25
+ * runs it in CI (`.github/workflows/ci.yml:22`). The tests in
26
+ * `queries.test.ts` catch only what a splice leaves visible in the finished
27
+ * string - a quoted literal the definition did not declare, or anything
28
+ * day-shaped. They do not catch the rest: splicing `'0 OR 1=1'` into
29
+ * `status-codes` as `` AND status >= ${'0 OR 1=1'} `` leaves the whole suite
30
+ * green while `tsc` reports `TS2345` (observed, 2026-08-30). What the net does
31
+ * still add is independence over the forms it does cover: vitest transpiles
32
+ * without typechecking, so `` AND day >= '${'2026-08-01' as SqlRelation}' ``
33
+ * passes `tsc` and reddens the parameterisation test anyway. Read the two as a
34
+ * check total up to a deliberate cast and a partial backstop under it, not as
35
+ * equals - weakening or dropping the branded types because "the tests cover
36
+ * it" would leave only the partial net.
37
+ *
38
+ * **Why the relation is a fixed name rather than the configured triple.** SQL
39
+ * binds *values*, not identifiers: a configured `<tableBucket>/<namespace>/
40
+ * <table>` spliced into the statement would be exactly the interpolation this
41
+ * module exists to make impossible. So every definition reads one relation,
42
+ * {@link PAGE_VIEWS_RELATION}, and binding that name to the configured triple
43
+ * is the adapter's job - it holds the plugin context, so it takes
44
+ * `resolveAnalyticsConfig(ctx)` and attaches or aliases accordingly. The
45
+ * configurability task 44 declares is therefore preserved, in the one place
46
+ * that has the environment to resolve it.
47
+ *
48
+ * Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
49
+ * `fetch`. Nothing here runs a statement - that is `AnalyticsQuery`'s adapter.
50
+ */
51
+ /**
52
+ * The relation every named query reads. Not "the configured table name" - the
53
+ * adapter binds this name to the configured `<namespace>.<table>` inside the
54
+ * attached catalog before it runs anything, and this module never sees the
55
+ * configuration. See the module doc comment for why an identifier cannot be a
56
+ * bind parameter.
57
+ */
58
+ export const PAGE_VIEWS_RELATION = 'page_views';
59
+ /** {@link PAGE_VIEWS_RELATION} as the one value the {@link sql} tag will splice. */
60
+ const PAGE_VIEWS = PAGE_VIEWS_RELATION;
61
+ /**
62
+ * Build a definition's statement. The rest parameter is typed
63
+ * {@link SqlRelation}, so `` sql`... WHERE day > ${params.range.from}` `` does
64
+ * not compile; a caller's value has to go through a `$name` placeholder.
65
+ */
66
+ function sql(fragments, ...relations) {
67
+ return fragments
68
+ .reduce((text, fragment, index) => text + (relations[index - 1] ?? '') + fragment)
69
+ .trim();
70
+ }
71
+ /**
72
+ * The parameters a definition binds, as `$name` placeholders in its SQL. Every
73
+ * query takes the date range and the bot-inclusion flag the spec requires of
74
+ * all of them; the list is per-definition so a statement that forgot one is a
75
+ * test failure naming that query rather than a filter that quietly never ran.
76
+ */
77
+ const QUERY_PARAM_NAMES = ['from', 'to', 'include_bots'];
78
+ /**
79
+ * The `x-edge-result-type` values CloudFront reports for a cache hit. AWS
80
+ * documents that field's values as `Hit`, `RefreshHit`, `Miss`,
81
+ * `LimitExceeded`, `CapacityExceeded`, `Error` and `Redirect`; the first two
82
+ * are the hits. `OriginShieldHit` is deliberately absent - it is an
83
+ * `x-edge-detailed-result-type` value, and `schema.ts` selects
84
+ * `x-edge-result-type` rather than the detailed field, so it can never appear
85
+ * in `result_type`.
86
+ */
87
+ const CACHE_HIT_RESULT_TYPES = ['Hit', 'RefreshHit'];
88
+ /**
89
+ * The name of the row-count query. Not one of the seven the spec's §Local
90
+ * server lists - those answer the dashboard's panels; this one answers
91
+ * `analytics status`, which reports the table's current row count beside the
92
+ * plugin's twelve nodes. It lives in this set rather than in the command
93
+ * because the command may not write SQL: every statement this package runs is
94
+ * one of these definitions, reached through the `AnalyticsQuery` port.
95
+ */
96
+ export const ROW_COUNT_QUERY = 'row-count';
97
+ /** The one column {@link ROW_COUNT_QUERY} selects. Named so no caller spells it twice. */
98
+ export const ROW_COUNT_COLUMN = 'row_count';
99
+ /**
100
+ * The range {@link ROW_COUNT_QUERY} is asked over when the caller wants the
101
+ * whole table, as `analytics status` does.
102
+ *
103
+ * Every definition in this set is bounded on the `day` partition - the spec
104
+ * requires the range and the bot flag of all of them - so "the whole table" is
105
+ * expressed as the widest range the column can hold rather than as an
106
+ * unbounded statement. Both ends are calendar days {@link isCalendarDay}
107
+ * accepts, so this constant goes through exactly the validation a caller's
108
+ * range does. `from` is the Unix epoch, which no `day` can precede: the column
109
+ * is derived from the request's own timestamp. `to` is the last day of the
110
+ * four-digit years, which is the largest day this module's `YYYY-MM-DD` shape
111
+ * can express at all.
112
+ */
113
+ export const WHOLE_TABLE_RANGE = { from: '1970-01-01', to: '9999-12-31' };
114
+ /**
115
+ * Every named query, keyed by the name a caller asks for: the seven the spec's
116
+ * §Local server lists, in its order, and then {@link ROW_COUNT_QUERY}, which
117
+ * no panel draws and `analytics status` reports.
118
+ *
119
+ * Every statement reads {@link PAGE_VIEWS}, bounds itself on the `day`
120
+ * partition with `$from`/`$to`, and honours `$include_bots` - a row whose
121
+ * `is_bot` is null counts as not-a-bot, since the transform leaves the column
122
+ * absent when it has nothing to say.
123
+ *
124
+ * **`unique-visitors` is the one whose semantic cannot be read off its name.**
125
+ * `visitor_key` is a daily-salted digest and the salt turns over at every UTC
126
+ * day boundary (the spec's Decision *Daily salt rotation stands*, settled
127
+ * 2026-07-27), so the same person is a different `visitor_key` tomorrow.
128
+ * A `count(DISTINCT visitor_key)` spanning a range therefore does not error -
129
+ * it returns a plausible number that means nothing. The definition counts
130
+ * distinct keys *within* a day and reports the range total as the sum of those
131
+ * daily counts, and says so in its column names (`daily_unique_visitors`,
132
+ * `summed_daily_unique_visitors`) and its `rowMeaning`, so a dashboard cannot
133
+ * relabel the total "unique visitors" without deleting the words that say
134
+ * otherwise. The sum over-counts a visitor who returns on another day; that is
135
+ * the accepted cost of bounding what one day of brute-forced salt could ever
136
+ * correlate.
137
+ */
138
+ export const ANALYTICS_QUERIES = {
139
+ 'views-over-time': {
140
+ rowMeaning: 'one UTC day and the number of requests served that day',
141
+ columns: ['day', 'is_bot'],
142
+ binds: ['from', 'to', 'include_bots'],
143
+ resultColumns: ['day', 'views'],
144
+ literals: [],
145
+ sql: sql `
146
+ SELECT day, count(*) AS views
147
+ FROM ${PAGE_VIEWS}
148
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
149
+ AND ($include_bots OR NOT coalesce(is_bot, false))
150
+ GROUP BY day
151
+ ORDER BY day
152
+ `,
153
+ },
154
+ 'top-paths': {
155
+ rowMeaning: 'one request path and the number of requests for it over the range',
156
+ columns: ['day', 'uri', 'is_bot'],
157
+ binds: ['from', 'to', 'include_bots'],
158
+ resultColumns: ['uri', 'views'],
159
+ literals: [],
160
+ sql: sql `
161
+ SELECT uri, count(*) AS views
162
+ FROM ${PAGE_VIEWS}
163
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
164
+ AND ($include_bots OR NOT coalesce(is_bot, false))
165
+ GROUP BY uri
166
+ ORDER BY views DESC, uri
167
+ `,
168
+ },
169
+ referrers: {
170
+ rowMeaning: 'one referring URL and the number of requests it sent over the range',
171
+ columns: ['day', 'referrer', 'is_bot'],
172
+ binds: ['from', 'to', 'include_bots'],
173
+ resultColumns: ['referrer', 'views'],
174
+ literals: [],
175
+ sql: sql `
176
+ SELECT referrer, count(*) AS views
177
+ FROM ${PAGE_VIEWS}
178
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
179
+ AND ($include_bots OR NOT coalesce(is_bot, false))
180
+ AND referrer IS NOT NULL
181
+ GROUP BY referrer
182
+ ORDER BY views DESC, referrer
183
+ `,
184
+ },
185
+ countries: {
186
+ rowMeaning: 'one viewer country and the number of requests from it over the range',
187
+ columns: ['day', 'country', 'is_bot'],
188
+ binds: ['from', 'to', 'include_bots'],
189
+ resultColumns: ['country', 'views'],
190
+ literals: [],
191
+ sql: sql `
192
+ SELECT country, count(*) AS views
193
+ FROM ${PAGE_VIEWS}
194
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
195
+ AND ($include_bots OR NOT coalesce(is_bot, false))
196
+ AND country IS NOT NULL
197
+ GROUP BY country
198
+ ORDER BY views DESC, country
199
+ `,
200
+ },
201
+ 'status-codes': {
202
+ rowMeaning: 'one HTTP status code and the number of responses carrying it over the range',
203
+ columns: ['day', 'status', 'is_bot'],
204
+ binds: ['from', 'to', 'include_bots'],
205
+ resultColumns: ['status', 'views'],
206
+ literals: [],
207
+ sql: sql `
208
+ SELECT status, count(*) AS views
209
+ FROM ${PAGE_VIEWS}
210
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
211
+ AND ($include_bots OR NOT coalesce(is_bot, false))
212
+ GROUP BY status
213
+ ORDER BY status
214
+ `,
215
+ },
216
+ 'cache-hit-ratio': {
217
+ rowMeaning: 'one UTC day, its requests, the edge cache hits among them, and hits divided by requests',
218
+ columns: ['day', 'result_type', 'is_bot'],
219
+ binds: ['from', 'to', 'include_bots'],
220
+ resultColumns: ['day', 'requests', 'cache_hits', 'cache_hit_ratio'],
221
+ literals: CACHE_HIT_RESULT_TYPES,
222
+ sql: sql `
223
+ WITH daily AS (
224
+ SELECT day,
225
+ count(*) AS requests,
226
+ count(*) FILTER (WHERE result_type IN ('Hit', 'RefreshHit')) AS cache_hits
227
+ FROM ${PAGE_VIEWS}
228
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
229
+ AND ($include_bots OR NOT coalesce(is_bot, false))
230
+ GROUP BY day
231
+ )
232
+ SELECT day, requests, cache_hits, CAST(cache_hits AS DOUBLE) / requests AS cache_hit_ratio
233
+ FROM daily
234
+ ORDER BY day
235
+ `,
236
+ },
237
+ 'unique-visitors': {
238
+ rowMeaning: 'one UTC day and its distinct visitor_key count, beside the range total - the sum of those daily counts, not a distinct count across days, because the salt rotates daily',
239
+ columns: ['day', 'visitor_key', 'is_bot'],
240
+ binds: ['from', 'to', 'include_bots'],
241
+ resultColumns: ['day', 'daily_unique_visitors', 'summed_daily_unique_visitors'],
242
+ literals: [],
243
+ sql: sql `
244
+ WITH daily AS (
245
+ SELECT day, count(DISTINCT visitor_key) AS daily_unique_visitors
246
+ FROM ${PAGE_VIEWS}
247
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
248
+ AND ($include_bots OR NOT coalesce(is_bot, false))
249
+ AND visitor_key IS NOT NULL
250
+ GROUP BY day
251
+ )
252
+ SELECT day,
253
+ daily_unique_visitors,
254
+ sum(daily_unique_visitors) OVER () AS summed_daily_unique_visitors
255
+ FROM daily
256
+ ORDER BY day
257
+ `,
258
+ },
259
+ /**
260
+ * Not a dashboard panel: the figure `analytics status` reports beside the
261
+ * node listing, so an operator can tell "the pipeline is provisioned" from
262
+ * "the pipeline has delivered something". Bots are counted when the caller
263
+ * asks for them - a row is a row - which is why the status command binds
264
+ * `include_bots` explicitly rather than leaving it to `config.analytics.bots`.
265
+ */
266
+ [ROW_COUNT_QUERY]: {
267
+ rowMeaning: 'the number of rows the table holds over the range, one row carrying the count',
268
+ columns: ['day', 'is_bot'],
269
+ binds: ['from', 'to', 'include_bots'],
270
+ resultColumns: [ROW_COUNT_COLUMN],
271
+ literals: [],
272
+ sql: sql `
273
+ SELECT count(*) AS row_count
274
+ FROM ${PAGE_VIEWS}
275
+ WHERE day BETWEEN CAST($from AS DATE) AND CAST($to AS DATE)
276
+ AND ($include_bots OR NOT coalesce(is_bot, false))
277
+ `,
278
+ },
279
+ };
280
+ /**
281
+ * Every query name, in declaration order - what the unknown-name error lists
282
+ * and what the test suite iterates. Derived from the table rather than
283
+ * restated beside it, so the two cannot drift; the cast is `Object.keys`'
284
+ * `string[]` narrowed back to the keys it just enumerated.
285
+ */
286
+ export const ANALYTICS_QUERY_NAMES = Object.keys(ANALYTICS_QUERIES);
287
+ /**
288
+ * Whether bot-flagged rows are counted when a caller states no preference, per
289
+ * `bots` mode. Exhaustive over the union rather than a comparison against one
290
+ * spelling, so adding a third mode to task 44's config is a compile error here
291
+ * instead of a silent `false`. The *default value* is not restated: it is
292
+ * whatever `validateAnalyticsConfig` put on `config.bots`.
293
+ */
294
+ const BOTS_INCLUDED_BY_DEFAULT = {
295
+ flag: true,
296
+ filter: false,
297
+ };
298
+ /**
299
+ * A `YYYY-MM-DD` day, the form the `day` partition column takes. Four digits,
300
+ * so the extended-year forms `Date` also understands are not days here - see
301
+ * {@link isCalendarDay} for which of the two checks catches what.
302
+ */
303
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
304
+ /** Characters in a `YYYY-MM-DD` day - how much of an ISO timestamp is the day. */
305
+ const DAY_LENGTH = 10;
306
+ /** Render a rejected value for a message: strings quoted, as core's config messages quote them. */
307
+ function describeValue(value) {
308
+ return typeof value === 'string' ? `"${value}"` : String(value);
309
+ }
310
+ /**
311
+ * Whether `day` names a day that exists. Neither half is redundant, and they
312
+ * catch different things:
313
+ *
314
+ * - The round-trip is what rejects a *malformed or impossible* day, and it
315
+ * does most of the work. `Date.parse('2026-02-30T00:00:00Z')` does not fail,
316
+ * it rolls over to 2 March, so a mistyped range end would silently report a
317
+ * different month; comparing the parsed date's own ISO form back against the
318
+ * input catches that, and also catches `2026-8-1`, `20260801` and a day with
319
+ * a time already on it, which all parse to `NaN` here.
320
+ * - {@link DAY_PATTERN} is what rejects the *extended-year* forms `Date` also
321
+ * accepts. `'+271821-04'` is ten characters long and round-trips exactly -
322
+ * `new Date(Date.parse('+271821-04T00:00:00Z')).toISOString().slice(0, 10)`
323
+ * is `'+271821-04'` - so without the pattern it would be bound as a day and
324
+ * reach `CAST($from AS DATE)`. `'-000001-01'` likewise.
325
+ *
326
+ * `queries.test.ts` pins both: drop either line and a different test reddens.
327
+ */
328
+ function isCalendarDay(day) {
329
+ if (!DAY_PATTERN.test(day))
330
+ return false;
331
+ const time = Date.parse(`${day}T00:00:00Z`);
332
+ if (Number.isNaN(time))
333
+ return false;
334
+ return new Date(time).toISOString().slice(0, DAY_LENGTH) === day;
335
+ }
336
+ /**
337
+ * Resolve a name to its definition, raising with the available names when it
338
+ * is not one of them. Takes a `string` rather than a {@link QueryName} because
339
+ * this is the boundary the local server's HTTP path arrives at, where the
340
+ * compiler's guarantee has already been erased.
341
+ *
342
+ * The lookup is guarded by `Object.hasOwn` and not by a test for `undefined`,
343
+ * for the reason `build.ts`'s `contentType` is: an unguarded index answers
344
+ * every `Object.prototype` key with an inherited function, so
345
+ * `GET /api/queries/constructor` would resolve to a truthy "definition" and
346
+ * fail later as `definition.binds is not iterable` instead of the rejection
347
+ * below. The names in the message are the only ones that resolve.
348
+ */
349
+ export function queryDefinition(name) {
350
+ const definition = Object.hasOwn(ANALYTICS_QUERIES, name)
351
+ ? ANALYTICS_QUERIES[name]
352
+ : undefined;
353
+ if (definition === undefined) {
354
+ throw new Error(`unknown analytics query ${describeValue(name)} - available queries are ${ANALYTICS_QUERY_NAMES.join(', ')}`);
355
+ }
356
+ return definition;
357
+ }
358
+ /**
359
+ * Check the range at the same boundary the name is checked at, naming the
360
+ * offending value. The declared type says `from` and `to` are present strings,
361
+ * and the runtime says otherwise for the same reason `config.ts`'s
362
+ * `unsealEnvDerivedOverrides` does: the one seam that fills these is the
363
+ * dashboard's query string, where every value arrives as `string | undefined`
364
+ * and a narrowing can be forgotten. Defaulting an absent range would be worse
365
+ * than raising - a chart over "some window the server picked" is indexed by
366
+ * nothing the reader chose.
367
+ */
368
+ function validateRange(name, range) {
369
+ const from = range?.from;
370
+ const to = range?.to;
371
+ if (typeof from !== 'string' || !isCalendarDay(from)) {
372
+ throw new Error(`analytics query ${describeValue(name)}: range.from must be a YYYY-MM-DD calendar day, got ${describeValue(from)}`);
373
+ }
374
+ if (typeof to !== 'string' || !isCalendarDay(to)) {
375
+ throw new Error(`analytics query ${describeValue(name)}: range.to must be a YYYY-MM-DD calendar day, got ${describeValue(to)}`);
376
+ }
377
+ // Both are `YYYY-MM-DD`, so lexical order is chronological order.
378
+ if (from > to) {
379
+ throw new Error(`analytics query ${describeValue(name)}: range is inverted - from ${describeValue(from)} is after to ${describeValue(to)}`);
380
+ }
381
+ return { from, to };
382
+ }
383
+ /**
384
+ * Resolve a name and its parameters into the statement and the bindings an
385
+ * adapter runs - the one boundary where an unknown name and a bad range are
386
+ * both rejected, so every implementation of `AnalyticsQuery` (the DuckDB
387
+ * adapter and the fixture-backed fake alike) refuses the same inputs with the
388
+ * same messages.
389
+ *
390
+ * `config` is the validated `analytics` block off `ctx.pluginConfig`, taken for
391
+ * its `bots` mode alone: a resolved config satisfies it too, so a caller passes
392
+ * whichever it is holding.
393
+ */
394
+ export function prepareQuery(name, params, config) {
395
+ const definition = queryDefinition(name);
396
+ const range = validateRange(name, params.range);
397
+ const includeBots = params.includeBots ?? BOTS_INCLUDED_BY_DEFAULT[config.bots];
398
+ const available = {
399
+ from: range.from,
400
+ to: range.to,
401
+ include_bots: includeBots,
402
+ };
403
+ const bindings = {};
404
+ for (const bind of definition.binds)
405
+ bindings[bind] = available[bind];
406
+ return {
407
+ // Justified by `queryDefinition` above: it raised unless `name` is one of
408
+ // the table's own keys, which is exactly what `QueryName` enumerates.
409
+ name: name,
410
+ sql: definition.sql,
411
+ resultColumns: definition.resultColumns,
412
+ bindings,
413
+ };
414
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The single home for the `page_views` Iceberg table: its column set, its
3
+ * `day` partition, the CloudFront standard-logging (v2) fields the delivery
4
+ * selects, and the mapping between the two. The transform Lambda, the table
5
+ * node and the delivery node all read these constants instead of restating
6
+ * them - see [§Table schema](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
7
+ *
8
+ * Why this file is worth being careful with: Firehose matches incoming JSON
9
+ * keys to Iceberg column names **exactly** and silently discards any field
10
+ * that does not match a column - no error, no dead-letter record, nothing
11
+ * that shows up in a log. A typo here does not fail loudly; it fills a
12
+ * column with nulls forever, and nothing points back at this file as the
13
+ * cause. Column names are lowercase throughout - an S3 Tables catalog
14
+ * requirement, not a style choice.
15
+ *
16
+ * `cs(Cookie)` and `x-forwarded-for` carry personal data and have no
17
+ * analytic use, so they are never selected: they never reach Firehose, the
18
+ * transform, or the table. This governs the analytics delivery only. The
19
+ * site's existing CloudWatch delivery (`packages/cli/src/nodes.ts`'s
20
+ * `logDeliveryNode`, wired through `packages/core/src/aws/logs.ts`'s
21
+ * `createDelivery`) is created with no `recordFields`, so AWS's default
22
+ * field list - which includes both excluded fields - still applies to that
23
+ * copy. Narrowing that is a separate change to the site's node, not this
24
+ * one; do not "fix" that inconsistency here.
25
+ *
26
+ * Field names are verified against AWS's CloudFront standard-logging (v2)
27
+ * documentation (docs.aws.amazon.com/AmazonCloudFront, "Configure standard
28
+ * logging (v2)" and "Standard logging reference", current as of 2026-08-30):
29
+ * the `recordFields` the CreateDelivery API accepts, and the field
30
+ * descriptions in the log-file-field reference.
31
+ *
32
+ * Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
33
+ * `fetch`.
34
+ */
35
+ /**
36
+ * Iceberg primitive types used by the `page_views` table. Not exported: no
37
+ * consumer needs the bare union today - a future one can reach the same
38
+ * type as `PageViewsColumn['icebergType']`. Export it once a real consumer
39
+ * needs the union by name.
40
+ */
41
+ type IcebergType = 'string' | 'timestamp' | 'date' | 'int' | 'long' | 'double' | 'boolean';
42
+ /** One `page_views` column: its Iceberg type and whether Firehose may write a null. */
43
+ export interface PageViewsColumn {
44
+ readonly name: string;
45
+ readonly icebergType: IcebergType;
46
+ readonly required: boolean;
47
+ }
48
+ /**
49
+ * The `page_views` table, in the order the spec's `PageView` `$defs` block
50
+ * lists it. `required` is `true` exactly for the spec's `PageView.required`
51
+ * set (`event_time`, `day`, `host`, `uri`, `status`); every other column may
52
+ * be null - CloudFront itself writes `-` for several of these when a request
53
+ * has nothing to say (no referrer, no query string, and so on).
54
+ */
55
+ export declare const PAGE_VIEWS_COLUMNS: readonly [{
56
+ readonly name: "event_time";
57
+ readonly icebergType: "timestamp";
58
+ readonly required: true;
59
+ }, {
60
+ readonly name: "day";
61
+ readonly icebergType: "date";
62
+ readonly required: true;
63
+ }, {
64
+ readonly name: "host";
65
+ readonly icebergType: "string";
66
+ readonly required: true;
67
+ }, {
68
+ readonly name: "uri";
69
+ readonly icebergType: "string";
70
+ readonly required: true;
71
+ }, {
72
+ readonly name: "query";
73
+ readonly icebergType: "string";
74
+ readonly required: false;
75
+ }, {
76
+ readonly name: "method";
77
+ readonly icebergType: "string";
78
+ readonly required: false;
79
+ }, {
80
+ readonly name: "status";
81
+ readonly icebergType: "int";
82
+ readonly required: true;
83
+ }, {
84
+ readonly name: "referrer";
85
+ readonly icebergType: "string";
86
+ readonly required: false;
87
+ }, {
88
+ readonly name: "user_agent";
89
+ readonly icebergType: "string";
90
+ readonly required: false;
91
+ }, {
92
+ readonly name: "country";
93
+ readonly icebergType: "string";
94
+ readonly required: false;
95
+ }, {
96
+ readonly name: "asn";
97
+ readonly icebergType: "string";
98
+ readonly required: false;
99
+ }, {
100
+ readonly name: "edge_location";
101
+ readonly icebergType: "string";
102
+ readonly required: false;
103
+ }, {
104
+ readonly name: "result_type";
105
+ readonly icebergType: "string";
106
+ readonly required: false;
107
+ }, {
108
+ readonly name: "bytes_sent";
109
+ readonly icebergType: "long";
110
+ readonly required: false;
111
+ }, {
112
+ readonly name: "time_taken";
113
+ readonly icebergType: "double";
114
+ readonly required: false;
115
+ }, {
116
+ readonly name: "content_type";
117
+ readonly icebergType: "string";
118
+ readonly required: false;
119
+ }, {
120
+ readonly name: "protocol";
121
+ readonly icebergType: "string";
122
+ readonly required: false;
123
+ }, {
124
+ readonly name: "request_id";
125
+ readonly icebergType: "string";
126
+ readonly required: false;
127
+ }, {
128
+ readonly name: "visitor_key";
129
+ readonly icebergType: "string";
130
+ readonly required: false;
131
+ }, {
132
+ readonly name: "is_bot";
133
+ readonly icebergType: "boolean";
134
+ readonly required: false;
135
+ }];
136
+ /** Every valid `page_views` column name, derived from the table above. */
137
+ export type PageViewColumnName = (typeof PAGE_VIEWS_COLUMNS)[number]['name'];
138
+ /** `page_views` is partitioned by this column. */
139
+ export declare const PAGE_VIEWS_PARTITION_COLUMN: PageViewColumnName;
140
+ /** Maps an `IcebergType` to the TypeScript type `PageView` stores it as. */
141
+ type TsTypeOf<T extends IcebergType> = T extends 'string' | 'timestamp' | 'date' ? string : T extends 'int' | 'long' | 'double' ? number : T extends 'boolean' ? boolean : never;
142
+ type RequiredColumn = Extract<(typeof PAGE_VIEWS_COLUMNS)[number], {
143
+ required: true;
144
+ }>;
145
+ type OptionalColumn = Extract<(typeof PAGE_VIEWS_COLUMNS)[number], {
146
+ required: false;
147
+ }>;
148
+ /**
149
+ * One `page_views` row, derived from `PAGE_VIEWS_COLUMNS` rather than
150
+ * hand-typed, so the transform (which builds these) cannot invent a column
151
+ * name the table does not carry - a typo in a property name here is a
152
+ * compile error, not a silently-dropped Firehose field.
153
+ */
154
+ export type PageView = {
155
+ [C in RequiredColumn as C['name']]: TsTypeOf<C['icebergType']>;
156
+ } & {
157
+ [C in OptionalColumn as C['name']]?: TsTypeOf<C['icebergType']>;
158
+ };
159
+ /**
160
+ * The CloudFront viewer-IP field. Selected so the transform can derive
161
+ * `visitor_key` from it (IP + user agent + a secret daily salt); the raw
162
+ * value is discarded by the transform and never written to any column, so
163
+ * it has no entry in `FIELD_TO_COLUMN`.
164
+ */
165
+ export declare const VIEWER_IP_FIELD = "c-ip";
166
+ /**
167
+ * The CloudFront millisecond-epoch timestamp field. Selected so the
168
+ * transform can derive both `event_time` and the `day` partition from it;
169
+ * because it feeds two columns rather than renaming into one, it has no
170
+ * entry in `FIELD_TO_COLUMN` either. Exported for its one consumer,
171
+ * `transform/map-record.ts`, which reads the field off the record and must
172
+ * not spell its name a second time - the field name is not a valid column
173
+ * name, so a divergence between the two spellings would silently stop
174
+ * filling `event_time` and `day`.
175
+ */
176
+ export declare const TIMESTAMP_MS_FIELD = "timestamp(ms)";
177
+ /**
178
+ * Selected CloudFront fields that feed a `DERIVED_COLUMNS` entry instead of
179
+ * being renamed 1:1 into a column of their own.
180
+ */
181
+ export declare const DERIVATION_ONLY_FIELDS: readonly ["c-ip", "timestamp(ms)"];
182
+ /**
183
+ * The CloudFront standard-logging (v2) field name each `page_views` column
184
+ * is filled from, a straight rename with no other transformation. Field
185
+ * names and meanings are as AWS documents them under "Standard logging
186
+ * reference":
187
+ *
188
+ * - `x-host-header` (not `cs(Host)`) is the Host header the viewer actually
189
+ * sent - the alternate domain name (CNAME) when the site has one, and the
190
+ * distribution's own domain otherwise. `cs(Host)` always reports the raw
191
+ * CloudFront distribution domain regardless of what the viewer requested,
192
+ * which would make `host` a constant for every custom-domain site.
193
+ * - `x-edge-result-type` (not `x-edge-response-result-type` or
194
+ * `x-edge-detailed-result-type`) is the standard hit/miss/error
195
+ * classification after the response finished sending - the field AWS's own
196
+ * sample cache-hit-ratio queries group by. The other two exist for
197
+ * diagnosing mid-response client disconnects and origin-error detail,
198
+ * which this table does not carry a column for.
199
+ * - `sc-bytes` (not `cs-bytes` or `sc-content-len`) is the total bytes
200
+ * CloudFront sent to the viewer, matching `bytes_sent`; `cs-bytes` is the
201
+ * viewer's request size and `sc-content-len` is only the `Content-Length`
202
+ * header value.
203
+ */
204
+ export declare const FIELD_TO_COLUMN: {
205
+ readonly 'x-host-header': "host";
206
+ readonly 'cs-uri-stem': "uri";
207
+ readonly 'cs-uri-query': "query";
208
+ readonly 'cs-method': "method";
209
+ readonly 'sc-status': "status";
210
+ readonly 'cs(Referer)': "referrer";
211
+ readonly 'cs(User-Agent)': "user_agent";
212
+ readonly 'c-country': "country";
213
+ readonly asn: "asn";
214
+ readonly 'x-edge-location': "edge_location";
215
+ readonly 'x-edge-result-type': "result_type";
216
+ readonly 'sc-bytes': "bytes_sent";
217
+ readonly 'time-taken': "time_taken";
218
+ readonly 'sc-content-type': "content_type";
219
+ readonly 'cs-protocol': "protocol";
220
+ readonly 'x-edge-request-id': "request_id";
221
+ };
222
+ /**
223
+ * The CloudFront fields the analytics delivery selects: every
224
+ * `FIELD_TO_COLUMN` key plus the two derivation-only inputs. `cs(Cookie)`
225
+ * and `x-forwarded-for` are deliberately absent - see the module doc comment.
226
+ */
227
+ export declare const CLOUDFRONT_RECORD_FIELDS: readonly [...string[], "c-ip", "timestamp(ms)"];
228
+ /**
229
+ * The four `page_views` columns no CloudFront field maps to 1:1 - each is
230
+ * computed by the transform from one or more selected fields rather than
231
+ * renamed from a single one:
232
+ *
233
+ * - `event_time` and `day` are both derived from `timestamp(ms)`.
234
+ * - `visitor_key` is a salted hash of `c-ip`, `cs(User-Agent)` and a secret
235
+ * daily salt - no single field determines it.
236
+ * - `is_bot` is a user-agent match against `cs(User-Agent)`, which already
237
+ * has its own entry in `FIELD_TO_COLUMN` (-> `user_agent`).
238
+ */
239
+ export declare const DERIVED_COLUMNS: readonly ["event_time", "day", "visitor_key", "is_bot"];
240
+ export {};