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
package/dist/config.js ADDED
@@ -0,0 +1,317 @@
1
+ /**
2
+ * This package owns the `analytics` config key end to end: its shape, its
3
+ * defaults, and the validator core's `parseConfig` deliberately does not run
4
+ * (`packages/core/src/config.ts:242` merges an `analytics` block through
5
+ * untouched, so a config carrying one is valid and inert when the plugin is
6
+ * not installed).
7
+ *
8
+ * **Two shapes, and why the seam between them is where it is.**
9
+ * `validateAnalyticsConfig` is the function this package's
10
+ * `Plugin.validateConfig` hands core's raw block to, so whatever it returns is
11
+ * what the host puts on `ctx.pluginConfig` (`packages/core/src/plugin.ts:85-93`).
12
+ * It applies every default it can: `namespace`, `table`, `bots` and
13
+ * `dashboard.port` default to plain literals, so they come back total and no
14
+ * reader downstream re-checks them for `undefined`.
15
+ *
16
+ * `tableBucket` and `saltSecretName` cannot be defaulted there. Both carry the
17
+ * environment, and the SPI hands `validateConfig` the block and nothing else -
18
+ * no `env`, no `siteName`. So an operator's overrides for those two are
19
+ * carried under {@link ENV_DERIVED}, a symbol this module does not export, and
20
+ * {@link resolveAnalyticsConfig} - which takes the plugin context, and
21
+ * therefore always has the environment - is the only way to a bucket name or a
22
+ * salt name.
23
+ *
24
+ * That sealing is load-bearing, not tidiness. Were `tableBucket` merely
25
+ * optional on the validated block, a node downstream could write
26
+ * ``ctx.pluginConfig.tableBucket ?? `${ctx.config.siteName}-analytics` `` and
27
+ * it would compile, typecheck and pass its own tests - and staging would then
28
+ * resolve to production's bucket. With the field absent from the type, that
29
+ * read is `TS2339: Property 'tableBucket' does not exist on type
30
+ * 'AnalyticsConfig'`, which `pnpm typecheck` fails on; `config.test.ts` pins
31
+ * the diagnostic so the seal cannot be opened silently.
32
+ *
33
+ * **Every derived default carries the environment**, matching `deriveNames`'
34
+ * `<env>-<siteName>` prefix (`packages/core/src/config.ts:352`):
35
+ * `tableBucket` is `<env>-<siteName>-analytics` and `saltSecretName` is
36
+ * `<siteName>/<env>/analytics-salt`. Do not "simplify" the environment out of
37
+ * either one. Without it staging and production resolve to the *same* Iceberg
38
+ * table and the same salt, and `blogwright analytics destroy --yes` run in
39
+ * staging issues `DeleteTableBucket` against production's data. Nothing in the
40
+ * system catches that: state is scoped per environment
41
+ * (`state/<env>.analytics.json`), so each environment's state file correctly
42
+ * records the bucket it was told to use and neither can see the collision.
43
+ * AWS gives a second, quieter reason - it "does not recommend using multiple
44
+ * Firehose streams to write data to the same Apache Iceberg table", because
45
+ * Iceberg's optimistic concurrency makes the streams contend.
46
+ *
47
+ * Pure data and pure functions only: no `node:` builtin, no vendor SDK, no
48
+ * `fetch`. See [the change spec's §Configuration → The `analytics`
49
+ * block](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
50
+ */
51
+ /**
52
+ * The key an operator's `tableBucket`/`saltSecretName` overrides ride on. A
53
+ * module-private `unique symbol`: TypeScript emits it into `config.d.ts`
54
+ * unexported, so no module outside this one can name it, and neither
55
+ * `ctx.pluginConfig.tableBucket` nor `ctx.pluginConfig[ENV_DERIVED]` compiles
56
+ * anywhere else. That is the point - see this module's doc comment for the
57
+ * `?? <env-less fallback>` line it exists to make unwritable.
58
+ */
59
+ const ENV_DERIVED = Symbol('analytics env-derived overrides');
60
+ /** Default Iceberg namespace. */
61
+ const DEFAULT_NAMESPACE = 'web';
62
+ /** Default Iceberg table - the one `schema.ts` describes. */
63
+ const DEFAULT_TABLE = 'page_views';
64
+ /** Default bot handling: keep bot rows, mark them, and leave filtering to the query. */
65
+ const DEFAULT_BOTS = 'flag';
66
+ /**
67
+ * Default port for the local dashboard server. Exported because the dashboard
68
+ * server binds this exact number: it imports the constant rather than
69
+ * restating it, so the default has one home.
70
+ */
71
+ export const DEFAULT_DASHBOARD_PORT = 4317;
72
+ /** Lowest port the dashboard may bind - anything below it needs root on Unix. */
73
+ const MIN_DASHBOARD_PORT = 1024;
74
+ /** Highest port there is. */
75
+ const MAX_DASHBOARD_PORT = 65535;
76
+ /** Shortest S3 bucket name S3 accepts. */
77
+ const TABLE_BUCKET_MIN_LENGTH = 3;
78
+ /** Longest S3 bucket name S3 accepts - the same limit `deriveNames` enforces. */
79
+ const TABLE_BUCKET_MAX_LENGTH = 63;
80
+ /**
81
+ * S3 bucket naming, narrowed to what an S3 Tables bucket takes: lowercase
82
+ * alphanumerics and dashes, {@link TABLE_BUCKET_MIN_LENGTH} to
83
+ * {@link TABLE_BUCKET_MAX_LENGTH} characters. Composed from those two
84
+ * constants rather than hard-coded inside the pattern, so the bounds have one
85
+ * home and the rejection message quotes the same numbers the pattern enforces.
86
+ */
87
+ const TABLE_BUCKET_PATTERN = new RegExp(`^[0-9a-z-]{${TABLE_BUCKET_MIN_LENGTH},${TABLE_BUCKET_MAX_LENGTH}}$`);
88
+ /** Iceberg identifier: an S3 Tables catalog rejects anything else. */
89
+ const IDENTIFIER_PATTERN = /^[a-z0-9_]+$/;
90
+ /** Characters permitted in a Secrets Manager secret name - the class `pds.secretName` uses. */
91
+ const SECRET_NAME_PATTERN = /^[\w/+=.@-]+$/;
92
+ /** The `bots` union, as data, so the validator and its message read one list. */
93
+ const BOTS_MODES = ['flag', 'filter'];
94
+ /** Every key the block allows - the schema's `additionalProperties: false`, as data. */
95
+ const ANALYTICS_KEYS = [
96
+ 'tableBucket',
97
+ 'namespace',
98
+ 'table',
99
+ 'bots',
100
+ 'saltSecretName',
101
+ 'dashboard',
102
+ ];
103
+ /** Every key the `dashboard` sub-block allows. */
104
+ const DASHBOARD_KEYS = ['port'];
105
+ /**
106
+ * Narrow an `unknown` to an object. The `!== null` is the `typeof` guard's
107
+ * usual companion, not a domain value: nothing in this module returns or
108
+ * accepts `null` for a setting - absence is `undefined`.
109
+ */
110
+ function isRecord(value) {
111
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
112
+ }
113
+ /** Render a rejected value for a message: strings quoted, as core's messages quote them. */
114
+ function formatValue(value) {
115
+ if (typeof value === 'string')
116
+ return `"${value}"`;
117
+ if (isRecord(value) || Array.isArray(value))
118
+ return JSON.stringify(value);
119
+ return String(value);
120
+ }
121
+ function isBotHandling(value) {
122
+ return BOTS_MODES.some((mode) => mode === value);
123
+ }
124
+ /**
125
+ * The `tableBucket` default. Carries `env` first, matching `deriveNames`'
126
+ * `<env>-<siteName>` prefix - see this module's doc comment for what dropping
127
+ * it destroys.
128
+ */
129
+ function defaultTableBucket(site) {
130
+ return `${site.env}-${site.siteName}-analytics`;
131
+ }
132
+ /**
133
+ * The `saltSecretName` default. Carries `env` as a path segment, so two
134
+ * environments hash `visitor_key` with two different salts.
135
+ */
136
+ function defaultSaltSecretName(site) {
137
+ return `${site.siteName}/${site.env}/analytics-salt`;
138
+ }
139
+ /**
140
+ * Validate a raw `analytics` config block, boundary-checked as `unknown`
141
+ * because it comes off `parseConfigDocument`'s `raw` half (a plugin's block
142
+ * has no type until its own package narrows it). An absent block validates as
143
+ * an empty one, so installing the plugin without writing an `analytics` key is
144
+ * valid.
145
+ *
146
+ * Applies the four literal defaults, so `namespace`, `table`, `bots` and
147
+ * `dashboard.port` are total on the returned block. `tableBucket` and
148
+ * `saltSecretName` are validated here too, but sealed under
149
+ * {@link ENV_DERIVED}: their defaults carry the environment, which this
150
+ * signature does not, so {@link resolveAnalyticsConfig} is where they resolve.
151
+ *
152
+ * Raises in the repo's vocabulary (`packages/core/src/config.ts:274-340`),
153
+ * naming the offending key and value.
154
+ */
155
+ export function validateAnalyticsConfig(raw) {
156
+ const block = raw === undefined ? {} : raw;
157
+ if (!isRecord(block)) {
158
+ throw new Error(`config.analytics must be an object, got ${formatValue(block)}`);
159
+ }
160
+ for (const key of Object.keys(block)) {
161
+ if (!ANALYTICS_KEYS.some((known) => known === key)) {
162
+ throw new Error(`config.analytics.${key} is not a known setting - allowed keys are ${ANALYTICS_KEYS.join(', ')}`);
163
+ }
164
+ }
165
+ return {
166
+ namespace: validateIdentifier(block['namespace'], 'namespace') ?? DEFAULT_NAMESPACE,
167
+ table: validateIdentifier(block['table'], 'table') ?? DEFAULT_TABLE,
168
+ bots: validateBots(block['bots']) ?? DEFAULT_BOTS,
169
+ dashboard: { port: validateDashboardPort(block['dashboard']) },
170
+ [ENV_DERIVED]: validateEnvDerivedOverrides(block),
171
+ };
172
+ }
173
+ /**
174
+ * Validate the two settings whose defaults need the environment, returning
175
+ * them as the sealed overrides {@link resolveAnalyticsConfig} later reads. An
176
+ * absent setting stays absent: this function has no environment to default it
177
+ * with, which is the whole reason the seal exists.
178
+ */
179
+ function validateEnvDerivedOverrides(raw) {
180
+ const overrides = {};
181
+ const tableBucket = raw['tableBucket'];
182
+ if (tableBucket !== undefined) {
183
+ if (typeof tableBucket !== 'string' || !TABLE_BUCKET_PATTERN.test(tableBucket)) {
184
+ throw new Error(`config.analytics.tableBucket must be ${TABLE_BUCKET_MIN_LENGTH}..${TABLE_BUCKET_MAX_LENGTH} lowercase alphanumeric/dash characters, got ${formatValue(tableBucket)}`);
185
+ }
186
+ overrides.tableBucket = tableBucket;
187
+ }
188
+ const saltSecretName = raw['saltSecretName'];
189
+ if (saltSecretName !== undefined) {
190
+ // Same character class and same message shape as `config.pds.secretName`
191
+ // (`packages/core/src/config.ts:337`): both name a Secrets Manager secret,
192
+ // so an operator who has hit one message recognises the other.
193
+ if (typeof saltSecretName !== 'string' || !SECRET_NAME_PATTERN.test(saltSecretName)) {
194
+ throw new Error(`config.analytics.saltSecretName has invalid characters: ${formatValue(saltSecretName)}`);
195
+ }
196
+ overrides.saltSecretName = saltSecretName;
197
+ }
198
+ return overrides;
199
+ }
200
+ /**
201
+ * Validate an Iceberg identifier (`namespace`, `table`), returning `undefined`
202
+ * when the setting is absent so the caller applies its own named default.
203
+ */
204
+ function validateIdentifier(raw, key) {
205
+ if (raw === undefined)
206
+ return undefined;
207
+ if (typeof raw !== 'string' || !IDENTIFIER_PATTERN.test(raw)) {
208
+ throw new Error(`config.analytics.${key} must be lowercase alphanumeric/underscores, got ${formatValue(raw)}`);
209
+ }
210
+ return raw;
211
+ }
212
+ /** Validate the `bots` setting, returning `undefined` when it is absent. */
213
+ function validateBots(raw) {
214
+ if (raw === undefined)
215
+ return undefined;
216
+ if (!isBotHandling(raw)) {
217
+ throw new Error(`config.analytics.bots must be one of ${BOTS_MODES.join(', ')}, got ${formatValue(raw)}`);
218
+ }
219
+ return raw;
220
+ }
221
+ /**
222
+ * Validate the `dashboard` sub-block and return the port it settles on -
223
+ * {@link DEFAULT_DASHBOARD_PORT} when the sub-block or the setting is absent.
224
+ * Split out to keep the validator at one level of detail.
225
+ */
226
+ function validateDashboardPort(raw) {
227
+ if (raw === undefined)
228
+ return DEFAULT_DASHBOARD_PORT;
229
+ if (!isRecord(raw)) {
230
+ throw new Error(`config.analytics.dashboard must be an object, got ${formatValue(raw)}`);
231
+ }
232
+ for (const key of Object.keys(raw)) {
233
+ if (!DASHBOARD_KEYS.some((known) => known === key)) {
234
+ throw new Error(`config.analytics.dashboard.${key} is not a known setting - allowed keys are ${DASHBOARD_KEYS.join(', ')}`);
235
+ }
236
+ }
237
+ const port = raw['port'];
238
+ if (port === undefined)
239
+ return DEFAULT_DASHBOARD_PORT;
240
+ // One message for one validity condition: an integer inside the range. A
241
+ // fractional or non-numeric port is outside that range as much as `80` is.
242
+ // The `typeof` does the narrowing `Number.isInteger` does not - its lib
243
+ // signature returns `boolean`, not a predicate - so at runtime it is
244
+ // redundant with the next clause, which already rejects every non-number.
245
+ if (typeof port !== 'number' ||
246
+ !Number.isInteger(port) ||
247
+ port < MIN_DASHBOARD_PORT ||
248
+ port > MAX_DASHBOARD_PORT) {
249
+ throw new Error(`config.analytics.dashboard.port must be in ${MIN_DASHBOARD_PORT}..${MAX_DASHBOARD_PORT}, got ${formatValue(port)}`);
250
+ }
251
+ return port;
252
+ }
253
+ /**
254
+ * Unseal the environment-carrying overrides off a validated block, rejecting a
255
+ * block that never went through {@link validateAnalyticsConfig}.
256
+ *
257
+ * The type says that cannot happen and the runtime says otherwise, because the
258
+ * one seam that fills `ctx.pluginConfig` erases `TConfig`: the CLI dispatches
259
+ * over `Plugin<unknown>` and `toPluginContext(ops)` returns
260
+ * `PluginContext<unknown>` (`packages/cli/src/plugin-commands.ts:241,466`), so
261
+ * an unvalidated block passes the compiler exactly there - and
262
+ * {@link resolveAnalyticsConfig} is exported, so a caller can hand one over
263
+ * directly too. Such a block carries neither this symbol nor the four literal
264
+ * defaults, and without this check the first read raised `TypeError: Cannot
265
+ * read properties of undefined (reading 'tableBucket')` - an internal field
266
+ * name and no fix.
267
+ *
268
+ * Nothing is recovered here, deliberately. The literal defaults are missing on
269
+ * that block as well, so a fabricated one would carry `undefined` at runtime
270
+ * for `namespace`, `table`, `bots` and `dashboard.port` - fields whose type
271
+ * says total - and every reader downstream trusts that type. A throw naming
272
+ * the function that must run first is loud; a recovered block is silently
273
+ * wrong.
274
+ */
275
+ function unsealEnvDerivedOverrides(block) {
276
+ // Justified by the line below, which is the validation: the declared type
277
+ // has no `undefined` here precisely because a validated block always has it.
278
+ const overrides = block[ENV_DERIVED];
279
+ if (overrides === undefined) {
280
+ throw new Error('analytics config was not validated: ctx.pluginConfig must come from validateAnalyticsConfig, which applies the defaults this resolver reads');
281
+ }
282
+ return overrides;
283
+ }
284
+ /**
285
+ * Resolve the environment-carrying settings a validated block sealed, giving
286
+ * the total config every reader downstream holds. Takes the plugin context
287
+ * rather than a `{ env, siteName }` pair, so the site identity is read out of
288
+ * `ctx` in one place instead of at each call site and no caller can supply an
289
+ * environment other than the one it is running in.
290
+ *
291
+ * Raises when handed a block the validator did not produce - see
292
+ * {@link unsealEnvDerivedOverrides}.
293
+ *
294
+ * The derived bucket name is length-checked here, where it is derived, exactly
295
+ * as `deriveNames` checks the site bucket it derives
296
+ * (`packages/core/src/config.ts:355`). Only the length is checked: `env` and
297
+ * `siteName` are already held to `^[a-z0-9-]+$` by `deriveNames` and
298
+ * `validateConfig`, and a second character check here would be a second home
299
+ * for a rule core already owns.
300
+ */
301
+ export function resolveAnalyticsConfig(ctx) {
302
+ const site = { env: ctx.env, siteName: ctx.config.siteName };
303
+ const block = ctx.pluginConfig;
304
+ const overrides = unsealEnvDerivedOverrides(block);
305
+ const tableBucket = overrides.tableBucket ?? defaultTableBucket(site);
306
+ if (tableBucket.length > TABLE_BUCKET_MAX_LENGTH) {
307
+ throw new Error(`derived analytics table bucket "${tableBucket}" exceeds S3's ${TABLE_BUCKET_MAX_LENGTH}-char limit; shorten env or siteName`);
308
+ }
309
+ return {
310
+ tableBucket,
311
+ namespace: block.namespace,
312
+ table: block.table,
313
+ bots: block.bots,
314
+ saltSecretName: overrides.saltSecretName ?? defaultSaltSecretName(site),
315
+ dashboard: { port: block.dashboard.port },
316
+ };
317
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The recording {@link AnalyticsIngest} the backfill's tests substitute at the
3
+ * write port, so no test in this package starts DuckDB to prove what was
4
+ * written. It is the write-side counterpart of `fixture-query.ts`, and it is
5
+ * shipped beside the interface it implements for the same reason
6
+ * `createMemoryFileSystem` (`packages/core/src/adapters/memory-fs.ts`) is: a
7
+ * real implementation of the port, not a stub that agrees with anything.
8
+ *
9
+ * **It records rather than judges, with two exceptions, and both are the
10
+ * contract rather than a convenience.** {@link AnalyticsIngest.insertDay}
11
+ * documents that a batch is one whole day and that an empty batch is a caller
12
+ * error; a fake that quietly accepted a row belonging to another day, or an
13
+ * insert of nothing, would let a backfill defect pass through it and land on
14
+ * an assertion about something else. Everything a test actually asserts - which
15
+ * days were written, in which order, with which rows - it reads off
16
+ * {@link RecordingAnalyticsIngest.calls}, and the fake takes no view on it.
17
+ * The bound (no day at or after the recorded `createdDay`) in particular is
18
+ * deliberately NOT enforced here: it is the property the backfill exists to
19
+ * hold, so a fake that enforced it would answer the question under test.
20
+ */
21
+ import type { AnalyticsIngest } from './ports.js';
22
+ import type { PageView } from './schema.js';
23
+ /**
24
+ * One accepted {@link AnalyticsIngest.insertDay}, exactly as it arrived.
25
+ *
26
+ * Not exported, following `ports.ts`' `QueryValue`: no consumer needs the
27
+ * shape by name today - a test reaches it as
28
+ * `RecordingAnalyticsIngest['calls'][number]` or, in practice, by reading
29
+ * `call.day` and `call.rows` off it. Export it once something names it.
30
+ */
31
+ interface RecordedInsert {
32
+ /** The UTC day the call named. */
33
+ readonly day: string;
34
+ /** The rows it carried, in the order the caller handed them over. */
35
+ readonly rows: readonly PageView[];
36
+ }
37
+ /** The fake, plus the record of what it was asked to write. */
38
+ export interface RecordingAnalyticsIngest extends AnalyticsIngest {
39
+ /** Every accepted insert, in call order. */
40
+ readonly calls: readonly RecordedInsert[];
41
+ /** The days of {@link calls}, in call order - the shape most assertions want. */
42
+ readonly days: readonly string[];
43
+ }
44
+ /**
45
+ * Build a {@link RecordingAnalyticsIngest}. Takes nothing: an ingest port has
46
+ * no result to seed, so the only thing a test needs from it is what it saw.
47
+ */
48
+ export declare function createRecordingAnalyticsIngest(): RecordingAnalyticsIngest;
49
+ export {};
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The recording {@link AnalyticsIngest} the backfill's tests substitute at the
3
+ * write port, so no test in this package starts DuckDB to prove what was
4
+ * written. It is the write-side counterpart of `fixture-query.ts`, and it is
5
+ * shipped beside the interface it implements for the same reason
6
+ * `createMemoryFileSystem` (`packages/core/src/adapters/memory-fs.ts`) is: a
7
+ * real implementation of the port, not a stub that agrees with anything.
8
+ *
9
+ * **It records rather than judges, with two exceptions, and both are the
10
+ * contract rather than a convenience.** {@link AnalyticsIngest.insertDay}
11
+ * documents that a batch is one whole day and that an empty batch is a caller
12
+ * error; a fake that quietly accepted a row belonging to another day, or an
13
+ * insert of nothing, would let a backfill defect pass through it and land on
14
+ * an assertion about something else. Everything a test actually asserts - which
15
+ * days were written, in which order, with which rows - it reads off
16
+ * {@link RecordingAnalyticsIngest.calls}, and the fake takes no view on it.
17
+ * The bound (no day at or after the recorded `createdDay`) in particular is
18
+ * deliberately NOT enforced here: it is the property the backfill exists to
19
+ * hold, so a fake that enforced it would answer the question under test.
20
+ */
21
+ /**
22
+ * Build a {@link RecordingAnalyticsIngest}. Takes nothing: an ingest port has
23
+ * no result to seed, so the only thing a test needs from it is what it saw.
24
+ */
25
+ export function createRecordingAnalyticsIngest() {
26
+ const calls = [];
27
+ const days = [];
28
+ return {
29
+ calls,
30
+ days,
31
+ async insertDay(day, rows) {
32
+ if (rows.length === 0) {
33
+ throw new Error(`analytics ingest was asked to insert day ${day} with no rows`);
34
+ }
35
+ const foreign = rows.find((row) => row.day !== day);
36
+ if (foreign !== undefined) {
37
+ throw new Error(`analytics ingest was asked to insert day ${day} carrying a row for day ${foreign.day}`);
38
+ }
39
+ calls.push({ day, rows });
40
+ days.push(day);
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The fixture-backed {@link AnalyticsQuery} every consumer's test substitutes
3
+ * at the port, so no test in this package starts DuckDB. It is the analytics
4
+ * counterpart of `packages/core/src/adapters/memory-fs.ts`: a real
5
+ * implementation of the port, seeded with data, shipped beside the interface
6
+ * it implements.
7
+ *
8
+ * It is not a stub that answers anything. It runs the *same*
9
+ * {@link prepareQuery} the DuckDB adapter runs, so an unknown name and an
10
+ * absent or inverted range are refused here with the same messages a real
11
+ * dashboard would produce, and it refuses at construction to hold rows that do
12
+ * not match the query's declared result columns. That second check is the
13
+ * point: a fake seeded with rows shaped like nothing the query returns lets a
14
+ * consumer's assertions pass for the wrong reason, which is the commonest way
15
+ * a test stops being able to fail.
16
+ *
17
+ * `calls` records what each accepted call resolved to, so a consumer can
18
+ * assert *what was bound* - that its date range reached the query, that its
19
+ * bot flag defaulted from `config.analytics.bots` - without a spy or a mock.
20
+ */
21
+ import { type AnalyticsConfig } from './config.js';
22
+ import type { AnalyticsQuery, QueryRow } from './ports.js';
23
+ import { type PreparedQuery, type QueryName } from './queries.js';
24
+ /** Rows to answer with, per query name. A name with no entry raises when asked for. */
25
+ export type QueryFixtures = Partial<Record<QueryName, readonly QueryRow[]>>;
26
+ /** The fake, plus the record of what it was asked. */
27
+ export interface FixtureAnalyticsQuery extends AnalyticsQuery {
28
+ /** Every accepted call, in order, as the shared lookup resolved it. */
29
+ readonly calls: readonly PreparedQuery[];
30
+ }
31
+ /**
32
+ * Build an {@link AnalyticsQuery} that answers from `fixtures`.
33
+ *
34
+ * `config` defaults to a validated empty `analytics` block, so a consumer that
35
+ * does not care about bot handling gets task 44's own default rather than a
36
+ * second copy of it stated here; a consumer that does care passes a block
37
+ * validated from the raw config it is exercising.
38
+ */
39
+ export declare function createFixtureAnalyticsQuery(fixtures: QueryFixtures, config?: Pick<AnalyticsConfig, 'bots'>): FixtureAnalyticsQuery;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The fixture-backed {@link AnalyticsQuery} every consumer's test substitutes
3
+ * at the port, so no test in this package starts DuckDB. It is the analytics
4
+ * counterpart of `packages/core/src/adapters/memory-fs.ts`: a real
5
+ * implementation of the port, seeded with data, shipped beside the interface
6
+ * it implements.
7
+ *
8
+ * It is not a stub that answers anything. It runs the *same*
9
+ * {@link prepareQuery} the DuckDB adapter runs, so an unknown name and an
10
+ * absent or inverted range are refused here with the same messages a real
11
+ * dashboard would produce, and it refuses at construction to hold rows that do
12
+ * not match the query's declared result columns. That second check is the
13
+ * point: a fake seeded with rows shaped like nothing the query returns lets a
14
+ * consumer's assertions pass for the wrong reason, which is the commonest way
15
+ * a test stops being able to fail.
16
+ *
17
+ * `calls` records what each accepted call resolved to, so a consumer can
18
+ * assert *what was bound* - that its date range reached the query, that its
19
+ * bot flag defaulted from `config.analytics.bots` - without a spy or a mock.
20
+ */
21
+ import { validateAnalyticsConfig } from './config.js';
22
+ import { prepareQuery, queryDefinition, } from './queries.js';
23
+ /** Render a list for a message, saying so when it is empty rather than trailing off. */
24
+ function formatList(items) {
25
+ return items.length === 0 ? 'none' : items.join(', ');
26
+ }
27
+ /**
28
+ * Refuse fixture rows that are not shaped like the query's own result. Checked
29
+ * once, at construction, so the failure names the test's own fixture rather
30
+ * than surfacing later as an assertion that passed against the wrong keys.
31
+ */
32
+ function checkFixtureShape(name, rows) {
33
+ const expected = [...queryDefinition(name).resultColumns].sort();
34
+ rows.forEach((row, index) => {
35
+ const actual = Object.keys(row).sort();
36
+ if (actual.join(',') !== expected.join(',')) {
37
+ throw new Error(`fixture row ${index} for analytics query "${name}" must carry exactly its result columns ${formatList(expected)}, got ${formatList(actual)}`);
38
+ }
39
+ });
40
+ }
41
+ /**
42
+ * Build an {@link AnalyticsQuery} that answers from `fixtures`.
43
+ *
44
+ * `config` defaults to a validated empty `analytics` block, so a consumer that
45
+ * does not care about bot handling gets task 44's own default rather than a
46
+ * second copy of it stated here; a consumer that does care passes a block
47
+ * validated from the raw config it is exercising.
48
+ */
49
+ export function createFixtureAnalyticsQuery(fixtures, config = validateAnalyticsConfig({})) {
50
+ const recorded = new Map();
51
+ for (const [name, rows] of Object.entries(fixtures)) {
52
+ if (rows === undefined)
53
+ continue;
54
+ checkFixtureShape(name, rows);
55
+ recorded.set(name, rows);
56
+ }
57
+ const calls = [];
58
+ return {
59
+ calls,
60
+ async run(name, params) {
61
+ const prepared = prepareQuery(name, params, config);
62
+ calls.push(prepared);
63
+ const rows = recorded.get(prepared.name);
64
+ if (rows === undefined) {
65
+ throw new Error(`no fixture rows recorded for analytics query "${prepared.name}" - recorded queries are ${formatList([...recorded.keys()])}`);
66
+ }
67
+ return rows;
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Public surface of blogwright-analytics: CloudFront access logs routed
3
+ * through Firehose into an Iceberg table, with a local dashboard over it.
4
+ * The plugin is installed on demand with `blogwright plugin add analytics`,
5
+ * never shipped with the CLI - see DEVELOPMENT.md §Hexagonal architecture
6
+ * ("Features live in their own packages"). This module carries the package's
7
+ * own AWS service clients (built over core's shared SigV4 transport through
8
+ * the plugin-supplied `ServiceDescriptor` seam) and, since task 47, the
9
+ * `Plugin` default export the CLI's discovery loads.
10
+ *
11
+ * The default export and the package's `blogwright.plugin` manifest field
12
+ * landed together, deliberately: a package carrying the manifest without a
13
+ * conforming default export is a discovery error naming this package, so
14
+ * neither half may ship ahead of the other.
15
+ */
16
+ /**
17
+ * The plugin's own four AWS service clients and the shapes they return. They
18
+ * live here rather than in `blogwright-core` because core enumerates no
19
+ * `firehose`, `glue`, `lambda` or `s3tables` signing name: each signs through
20
+ * the plugin-supplied `ServiceDescriptor` seam over the host's us-east-1
21
+ * signer. Each module carries the client's own documentation.
22
+ */
23
+ export * from './aws/firehose.js';
24
+ export * from './aws/glue.js';
25
+ export * from './aws/lambda.js';
26
+ export * from './aws/s3tables.js';
27
+ /**
28
+ * The `Plugin` object the CLI discovers, and the namespace constant its
29
+ * `name` is built from. Both live in `plugin.ts`; `ANALYTICS_NAMESPACE`
30
+ * moved there from this module when the default export arrived to consume
31
+ * it, so the namespace has one home rather than a constant here and a
32
+ * literal there. Its conformance to core's `PLUGIN_NAME_PATTERN` is pinned
33
+ * by `index.test.ts` and again, through `validatePlugin`, by `plugin.test.ts`.
34
+ */
35
+ export { default, ANALYTICS_NAMESPACE } from './plugin.js';
package/dist/index.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Public surface of blogwright-analytics: CloudFront access logs routed
3
+ * through Firehose into an Iceberg table, with a local dashboard over it.
4
+ * The plugin is installed on demand with `blogwright plugin add analytics`,
5
+ * never shipped with the CLI - see DEVELOPMENT.md §Hexagonal architecture
6
+ * ("Features live in their own packages"). This module carries the package's
7
+ * own AWS service clients (built over core's shared SigV4 transport through
8
+ * the plugin-supplied `ServiceDescriptor` seam) and, since task 47, the
9
+ * `Plugin` default export the CLI's discovery loads.
10
+ *
11
+ * The default export and the package's `blogwright.plugin` manifest field
12
+ * landed together, deliberately: a package carrying the manifest without a
13
+ * conforming default export is a discovery error naming this package, so
14
+ * neither half may ship ahead of the other.
15
+ */
16
+ /**
17
+ * The plugin's own four AWS service clients and the shapes they return. They
18
+ * live here rather than in `blogwright-core` because core enumerates no
19
+ * `firehose`, `glue`, `lambda` or `s3tables` signing name: each signs through
20
+ * the plugin-supplied `ServiceDescriptor` seam over the host's us-east-1
21
+ * signer. Each module carries the client's own documentation.
22
+ */
23
+ export * from './aws/firehose.js';
24
+ export * from './aws/glue.js';
25
+ export * from './aws/lambda.js';
26
+ export * from './aws/s3tables.js';
27
+ /**
28
+ * The `Plugin` object the CLI discovers, and the namespace constant its
29
+ * `name` is built from. Both live in `plugin.ts`; `ANALYTICS_NAMESPACE`
30
+ * moved there from this module when the default export arrived to consume
31
+ * it, so the namespace has one home rather than a constant here and a
32
+ * literal there. Its conformance to core's `PLUGIN_NAME_PATTERN` is pinned
33
+ * by `index.test.ts` and again, through `validatePlugin`, by `plugin.test.ts`.
34
+ */
35
+ export { default, ANALYTICS_NAMESPACE } from './plugin.js';