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,45 @@
1
+ /**
2
+ * The plugin's composition root for *paths*: the one module in
3
+ * `packages/analytics/src/` permitted to touch `import.meta.url`.
4
+ *
5
+ * `analytics-transform-function` (`nodes.ts`) deploys an artifact this package
6
+ * ships inside itself - the rolldown bundle and the manifest stamped beside it
7
+ * under `transform-hash.ts`'s `TRANSFORM_BUNDLE_DIR` - so something has to know where the
8
+ * installed package is on disk. The CLI answers the identical question for the
9
+ * build-agent at `packages/cli/src/context.ts` (`cliPackageDir()`, joined to
10
+ * `agent` and put on the context as `agentDir`), and the rule that keeps that
11
+ * honest is DEVELOPMENT.md §Hexagonal architecture: a location is resolved at
12
+ * a composition root and handed to domain code as *data*.
13
+ *
14
+ * `nodes.ts` has no wiring step of its own to resolve it in - the SPI reaches
15
+ * it through `plugin.nodes(ctx)` with nothing in between - so this module is
16
+ * that step. Resolving it inline in `nodes.ts` would satisfy the package's ban
17
+ * on Node's own filesystem module (`import.meta.url` is not a restricted
18
+ * import) while breaking the composition-root rule anyway, and it would put a
19
+ * second `import.meta.url` in the package the day a second artifact needs
20
+ * locating.
21
+ *
22
+ * This module resolves a directory and nothing else. The *bytes* under it are
23
+ * still read through `ctx.ports.fs`, never here: this package imports Node's
24
+ * filesystem module nowhere at all, and no `packages/analytics/src/` path joins
25
+ * the `no-restricted-imports` override list in `.oxlintrc.json`. (Named in
26
+ * prose rather than spelled as the specifier, so the definition of done's grep
27
+ * for it over this tree does not trip over a comment - the same care
28
+ * `ports.ts` takes with its own vendor name.)
29
+ */
30
+ /**
31
+ * The `blogwright-analytics` package root, resolved from this module's own
32
+ * location - `<package>/dist/paths.js` once compiled, `<package>/src/paths.ts`
33
+ * under vitest, and one level up from either.
34
+ *
35
+ * `resolve` is here for the reason `cliPackageDir()` states: `new URL('..', …)`
36
+ * yields a trailing separator, unlike every other directory value in the repo,
37
+ * so a caller writing `${ANALYTICS_PACKAGE_DIR}/x` would get a doubled one - in
38
+ * the path and in any error message built from it.
39
+ *
40
+ * The relative location of the transform artifacts under this directory is not
41
+ * restated here: `transform-hash.ts` owns `TRANSFORM_BUNDLE_DIR` as "where
42
+ * the build puts the Lambda artifacts, relative to the package root", and
43
+ * `nodes.ts` joins the two.
44
+ */
45
+ export declare const ANALYTICS_PACKAGE_DIR: string;
package/dist/paths.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The plugin's composition root for *paths*: the one module in
3
+ * `packages/analytics/src/` permitted to touch `import.meta.url`.
4
+ *
5
+ * `analytics-transform-function` (`nodes.ts`) deploys an artifact this package
6
+ * ships inside itself - the rolldown bundle and the manifest stamped beside it
7
+ * under `transform-hash.ts`'s `TRANSFORM_BUNDLE_DIR` - so something has to know where the
8
+ * installed package is on disk. The CLI answers the identical question for the
9
+ * build-agent at `packages/cli/src/context.ts` (`cliPackageDir()`, joined to
10
+ * `agent` and put on the context as `agentDir`), and the rule that keeps that
11
+ * honest is DEVELOPMENT.md §Hexagonal architecture: a location is resolved at
12
+ * a composition root and handed to domain code as *data*.
13
+ *
14
+ * `nodes.ts` has no wiring step of its own to resolve it in - the SPI reaches
15
+ * it through `plugin.nodes(ctx)` with nothing in between - so this module is
16
+ * that step. Resolving it inline in `nodes.ts` would satisfy the package's ban
17
+ * on Node's own filesystem module (`import.meta.url` is not a restricted
18
+ * import) while breaking the composition-root rule anyway, and it would put a
19
+ * second `import.meta.url` in the package the day a second artifact needs
20
+ * locating.
21
+ *
22
+ * This module resolves a directory and nothing else. The *bytes* under it are
23
+ * still read through `ctx.ports.fs`, never here: this package imports Node's
24
+ * filesystem module nowhere at all, and no `packages/analytics/src/` path joins
25
+ * the `no-restricted-imports` override list in `.oxlintrc.json`. (Named in
26
+ * prose rather than spelled as the specifier, so the definition of done's grep
27
+ * for it over this tree does not trip over a comment - the same care
28
+ * `ports.ts` takes with its own vendor name.)
29
+ */
30
+ import { resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ /**
33
+ * The `blogwright-analytics` package root, resolved from this module's own
34
+ * location - `<package>/dist/paths.js` once compiled, `<package>/src/paths.ts`
35
+ * under vitest, and one level up from either.
36
+ *
37
+ * `resolve` is here for the reason `cliPackageDir()` states: `new URL('..', …)`
38
+ * yields a trailing separator, unlike every other directory value in the repo,
39
+ * so a caller writing `${ANALYTICS_PACKAGE_DIR}/x` would get a doubled one - in
40
+ * the path and in any error message built from it.
41
+ *
42
+ * The relative location of the transform artifacts under this directory is not
43
+ * restated here: `transform-hash.ts` owns `TRANSFORM_BUNDLE_DIR` as "where
44
+ * the build puts the Lambda artifacts, relative to the package root", and
45
+ * `nodes.ts` joins the two.
46
+ */
47
+ export const ANALYTICS_PACKAGE_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The package's `Plugin` default export: the object the CLI's discovery
3
+ * loads once `package.json`'s `{ "blogwright": { "plugin": "analytics" } }`
4
+ * manifest field marks this package as a plugin. Everything the host learns
5
+ * about this plugin - its namespace, its help text, the config key it owns,
6
+ * its validator, its actions and its `init` contributor - it learns from
7
+ * here.
8
+ *
9
+ * **The command table, and what is deliberately missing from it.** Task 16's
10
+ * precedence rules (`packages/cli/src/plugin-commands.ts`, its TASK 13 and
11
+ * TASK 16 PRECEDENCE sections) decide which verbs a plugin may declare:
12
+ *
13
+ * - `bootstrap` and `destroy` are ALWAYS the CLI's generic verbs. They run
14
+ * `applyGraph`/`destroyGraph` over `nodes(ctx)`, and a plugin may not
15
+ * import the CLI, so a plugin cannot run that engine itself. Declaring
16
+ * either is not merely redundant, it is rejected at discovery -
17
+ * `rejectDeclaredLifecycleCollisions` (`packages/cli/src/plugins.ts`)
18
+ * turns the whole package into a load failure naming the colliding
19
+ * action. They are absent here for that reason, not for tidiness.
20
+ * - `status` is the generic verb UNLESS the plugin declares its own, and
21
+ * this one does: `analytics status` reports strictly more than the
22
+ * generic verb can - the stream's delivery health and the table's
23
+ * current row count on top of the node listing - and needs no engine
24
+ * call to do it, because `read()` lives on the plugin's own nodes.
25
+ * - `init` is ABSENT from the table on purpose, and this is the trap
26
+ * worth stating plainly: a declared command takes precedence over the
27
+ * generic action, so an `init` entry here would shadow the generic
28
+ * config-block splice. `blogwright analytics init` would then ask the
29
+ * operator every question below and write nothing at all. The `init`
30
+ * contributor ({@link Plugin.init}) is the ONLY way this plugin supplies
31
+ * `init`. (pds is the opposite case: it declares a real `init` command
32
+ * that creates the publication record and writes no config block, which
33
+ * precedence permits - a declared `init` owns the action, not an
34
+ * obligation to write config.) Declaring both is itself a discovery
35
+ * rejection, `rejectDeclaredInitCollisions` in the same collision pass.
36
+ * - `backfill` is legal to declare precisely because only `bootstrap` and
37
+ * `destroy` are reserved; it is the spec's optional, run-by-hand,
38
+ * one-shot action.
39
+ *
40
+ * None of the three declared actions is destructive, so no summary here
41
+ * states `--yes`. The one destructive verb this namespace answers is
42
+ * `analytics destroy --yes`, which is generic: its refusal without `--yes`
43
+ * is `runGenericDestroy`'s, and its summary is the CLI's own.
44
+ *
45
+ * **`nodes` is declared, and declaring it is what turns the generic verbs
46
+ * on.** `genericLifecycleCommand` and `genericLifecycleActions`
47
+ * (`packages/cli/src/plugin-commands.ts`) both gate all three generic verbs
48
+ * on `plugin.nodes` being declared at all, so before task 54 wired
49
+ * {@link buildAnalyticsNodes} to it, `analytics bootstrap` and `analytics
50
+ * destroy` were neither answered nor advertised in `blogwright --help`.
51
+ * They are both now, and `analytics status` still runs the declared command
52
+ * above rather than the generic verb, because a declared action wins.
53
+ *
54
+ * The property `nodes` carries is not just "twelve nodes": it is that the
55
+ * CLI's engine reconciles them against the store `toPluginContext` scoped to
56
+ * this plugin's name, `state/<env>.analytics.json` - never the site's
57
+ * `state/<env>.json`. `blogwright bootstrap` therefore provisions none of
58
+ * these twelve (the site's `buildNodes` does not consult discovery), and
59
+ * `blogwright destroy --yes` removes none of them, because
60
+ * `assertNoScopedState` (`packages/cli/src/commands.ts`) refuses the site
61
+ * teardown for as long as that scoped object exists and names
62
+ * `blogwright analytics destroy <env> --yes` as the way through.
63
+ *
64
+ * The builder itself is passed by reference - `nodes: buildAnalyticsNodes`,
65
+ * no wrapper. The array is not shared: each call builds a fresh one. There is
66
+ * nothing for a wrapper to do either, because the set does not vary with
67
+ * the context, and re-ordering it here would only hide that the order the
68
+ * builder returns is already a topological one.
69
+ *
70
+ * **`validateConfig` is task 44's validator, bound - not wrapped.** The
71
+ * property below is `validateAnalyticsConfig` itself. That matters twice
72
+ * over. It is the function that applies the four literal defaults and stamps
73
+ * the module-private symbol `resolveAnalyticsConfig` unseals
74
+ * (`config.ts`'s `unsealEnvDerivedOverrides`), so any wrapper that reshaped
75
+ * or re-defaulted the block would produce one the resolver rejects at
76
+ * runtime. And the CLI calls it with `undefined` when the operator's config
77
+ * carries no `analytics` key at all (`resolvePluginConfig`,
78
+ * `packages/cli/src/plugins.ts`, over `pluginBlock`'s plain property read),
79
+ * so the plugin's own defaults - not a bare `{}` - are what such an operator
80
+ * gets; `validateAnalyticsConfig` treats an absent block as an empty one for
81
+ * exactly that reason. Passing the function by reference keeps both
82
+ * properties instead of restating them.
83
+ */
84
+ import type { Plugin } from 'blogwright-core';
85
+ import { type AnalyticsConfig } from './config.js';
86
+ /**
87
+ * The CLI namespace this plugin claims (`blogwright analytics <action>`),
88
+ * and - deliberately the same string - the single top-level config key it
89
+ * owns (`config.analytics`), the way pds's namespace and key coincide too.
90
+ * Re-exported from `index.ts`, where it lived before the default export
91
+ * existed to consume it.
92
+ */
93
+ export declare const ANALYTICS_NAMESPACE = "analytics";
94
+ /**
95
+ * The plugin the CLI discovers. `Plugin<AnalyticsConfig>` names the block
96
+ * `validateConfig` returns, which is what the host puts on
97
+ * `ctx.pluginConfig` - the only route to this plugin's settings, and (for
98
+ * the two environment-carrying ones) only through
99
+ * `resolveAnalyticsConfig(ctx)`.
100
+ */
101
+ declare const analyticsPlugin: Plugin<AnalyticsConfig>;
102
+ export default analyticsPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The package's `Plugin` default export: the object the CLI's discovery
3
+ * loads once `package.json`'s `{ "blogwright": { "plugin": "analytics" } }`
4
+ * manifest field marks this package as a plugin. Everything the host learns
5
+ * about this plugin - its namespace, its help text, the config key it owns,
6
+ * its validator, its actions and its `init` contributor - it learns from
7
+ * here.
8
+ *
9
+ * **The command table, and what is deliberately missing from it.** Task 16's
10
+ * precedence rules (`packages/cli/src/plugin-commands.ts`, its TASK 13 and
11
+ * TASK 16 PRECEDENCE sections) decide which verbs a plugin may declare:
12
+ *
13
+ * - `bootstrap` and `destroy` are ALWAYS the CLI's generic verbs. They run
14
+ * `applyGraph`/`destroyGraph` over `nodes(ctx)`, and a plugin may not
15
+ * import the CLI, so a plugin cannot run that engine itself. Declaring
16
+ * either is not merely redundant, it is rejected at discovery -
17
+ * `rejectDeclaredLifecycleCollisions` (`packages/cli/src/plugins.ts`)
18
+ * turns the whole package into a load failure naming the colliding
19
+ * action. They are absent here for that reason, not for tidiness.
20
+ * - `status` is the generic verb UNLESS the plugin declares its own, and
21
+ * this one does: `analytics status` reports strictly more than the
22
+ * generic verb can - the stream's delivery health and the table's
23
+ * current row count on top of the node listing - and needs no engine
24
+ * call to do it, because `read()` lives on the plugin's own nodes.
25
+ * - `init` is ABSENT from the table on purpose, and this is the trap
26
+ * worth stating plainly: a declared command takes precedence over the
27
+ * generic action, so an `init` entry here would shadow the generic
28
+ * config-block splice. `blogwright analytics init` would then ask the
29
+ * operator every question below and write nothing at all. The `init`
30
+ * contributor ({@link Plugin.init}) is the ONLY way this plugin supplies
31
+ * `init`. (pds is the opposite case: it declares a real `init` command
32
+ * that creates the publication record and writes no config block, which
33
+ * precedence permits - a declared `init` owns the action, not an
34
+ * obligation to write config.) Declaring both is itself a discovery
35
+ * rejection, `rejectDeclaredInitCollisions` in the same collision pass.
36
+ * - `backfill` is legal to declare precisely because only `bootstrap` and
37
+ * `destroy` are reserved; it is the spec's optional, run-by-hand,
38
+ * one-shot action.
39
+ *
40
+ * None of the three declared actions is destructive, so no summary here
41
+ * states `--yes`. The one destructive verb this namespace answers is
42
+ * `analytics destroy --yes`, which is generic: its refusal without `--yes`
43
+ * is `runGenericDestroy`'s, and its summary is the CLI's own.
44
+ *
45
+ * **`nodes` is declared, and declaring it is what turns the generic verbs
46
+ * on.** `genericLifecycleCommand` and `genericLifecycleActions`
47
+ * (`packages/cli/src/plugin-commands.ts`) both gate all three generic verbs
48
+ * on `plugin.nodes` being declared at all, so before task 54 wired
49
+ * {@link buildAnalyticsNodes} to it, `analytics bootstrap` and `analytics
50
+ * destroy` were neither answered nor advertised in `blogwright --help`.
51
+ * They are both now, and `analytics status` still runs the declared command
52
+ * above rather than the generic verb, because a declared action wins.
53
+ *
54
+ * The property `nodes` carries is not just "twelve nodes": it is that the
55
+ * CLI's engine reconciles them against the store `toPluginContext` scoped to
56
+ * this plugin's name, `state/<env>.analytics.json` - never the site's
57
+ * `state/<env>.json`. `blogwright bootstrap` therefore provisions none of
58
+ * these twelve (the site's `buildNodes` does not consult discovery), and
59
+ * `blogwright destroy --yes` removes none of them, because
60
+ * `assertNoScopedState` (`packages/cli/src/commands.ts`) refuses the site
61
+ * teardown for as long as that scoped object exists and names
62
+ * `blogwright analytics destroy <env> --yes` as the way through.
63
+ *
64
+ * The builder itself is passed by reference - `nodes: buildAnalyticsNodes`,
65
+ * no wrapper. The array is not shared: each call builds a fresh one. There is
66
+ * nothing for a wrapper to do either, because the set does not vary with
67
+ * the context, and re-ordering it here would only hide that the order the
68
+ * builder returns is already a topological one.
69
+ *
70
+ * **`validateConfig` is task 44's validator, bound - not wrapped.** The
71
+ * property below is `validateAnalyticsConfig` itself. That matters twice
72
+ * over. It is the function that applies the four literal defaults and stamps
73
+ * the module-private symbol `resolveAnalyticsConfig` unseals
74
+ * (`config.ts`'s `unsealEnvDerivedOverrides`), so any wrapper that reshaped
75
+ * or re-defaulted the block would produce one the resolver rejects at
76
+ * runtime. And the CLI calls it with `undefined` when the operator's config
77
+ * carries no `analytics` key at all (`resolvePluginConfig`,
78
+ * `packages/cli/src/plugins.ts`, over `pluginBlock`'s plain property read),
79
+ * so the plugin's own defaults - not a bare `{}` - are what such an operator
80
+ * gets; `validateAnalyticsConfig` treats an absent block as an empty one for
81
+ * exactly that reason. Passing the function by reference keeps both
82
+ * properties instead of restating them.
83
+ */
84
+ import { backfill, dashboard, status } from './commands.js';
85
+ import { validateAnalyticsConfig } from './config.js';
86
+ import { buildAnalyticsNodes } from './nodes.js';
87
+ /**
88
+ * The CLI namespace this plugin claims (`blogwright analytics <action>`),
89
+ * and - deliberately the same string - the single top-level config key it
90
+ * owns (`config.analytics`), the way pds's namespace and key coincide too.
91
+ * Re-exported from `index.ts`, where it lived before the default export
92
+ * existed to consume it.
93
+ */
94
+ export const ANALYTICS_NAMESPACE = 'analytics';
95
+ /** The answers {@link isYes} accepts, matching `confirm`'s (`packages/cli/src/logger.ts`). */
96
+ const YES_ANSWERS = new Set(['y', 'yes']);
97
+ /** True when the operator agreed. Empty input never reaches here - `ask` substitutes the default first. */
98
+ function isYes(answer) {
99
+ return YES_ANSWERS.has(answer.trim().toLowerCase());
100
+ }
101
+ /**
102
+ * Run `block` past {@link validateAnalyticsConfig} and return the message it
103
+ * raised, or `undefined` when it holds - the shape `PluginQuestion.validate`
104
+ * asks for. Every prompt below validates through this, so the wizard rejects
105
+ * exactly what the config validator rejects, with the validator's own
106
+ * message, and neither list of rules nor set of messages has a second home.
107
+ *
108
+ * These validators are also the only thing standing between a typed answer
109
+ * and the JSON text the entries below interpolate it into. An answer carrying
110
+ * a quote would close its own string and open a setting the wizard never
111
+ * asked about - `tableBucket` above all, the field task 44 sealed behind a
112
+ * module-private symbol precisely so that no code path can produce an
113
+ * env-less bucket name (`config.ts`, and what an env-less one destroys). Such
114
+ * a splice would survive the host's re-parse AND `validateAnalyticsConfig`,
115
+ * because the key it opens is a legitimate one; what refuses it is the
116
+ * `table` prompt's own rule, that an Iceberg identifier has no quote in it.
117
+ */
118
+ function rejectionOf(block) {
119
+ try {
120
+ validateAnalyticsConfig(block);
121
+ return undefined;
122
+ }
123
+ catch (err) {
124
+ return err.message;
125
+ }
126
+ }
127
+ /**
128
+ * Validate a typed port answer. A non-numeric answer is handed to the
129
+ * validator as the string it is, so the rejection quotes what the operator
130
+ * typed rather than `NaN`.
131
+ */
132
+ function portRejection(answer) {
133
+ const numeric = Number(answer);
134
+ return rejectionOf({ dashboard: { port: Number.isNaN(numeric) ? answer : numeric } });
135
+ }
136
+ /**
137
+ * Ask the operator for this plugin's config block and return it as the
138
+ * property/comment entries `renderConfigBlock` (`packages/cli/src/config-block.ts`)
139
+ * renders - the entry shape `renderConfig` (`packages/cli/src/init.ts`)
140
+ * mirrors at the top level. Both paths that reach a contributor - the
141
+ * first-run wizard (`blogwright init`) and the generic per-plugin action
142
+ * (`blogwright analytics init`) - call this one function.
143
+ *
144
+ * Only the four settings whose defaults are plain literals are asked. The
145
+ * other two the block accepts - `tableBucket` and `saltSecretName` - are
146
+ * deliberately not asked: their defaults carry the environment (`config.ts`),
147
+ * a contributor is handed no environment (`PluginInitIo` is terminal-shaped
148
+ * and nothing more), and a prompt whose default is wrong for every
149
+ * environment but one is worse than no prompt at all. An operator overriding
150
+ * either writes it into the block by hand, where the validator still checks
151
+ * it.
152
+ *
153
+ * The defaults offered come from `validateAnalyticsConfig(undefined)` -
154
+ * task 44's validator applied to an absent block - rather than from
155
+ * constants restated here, so the value the wizard offers is by construction
156
+ * the value an operator gets by leaving the setting out. That is also the
157
+ * exact call the CLI makes for an operator with no `analytics` key.
158
+ *
159
+ * No question carries `required`. Every default below is a non-empty string,
160
+ * and `ask` (`packages/cli/src/init.ts`) substitutes the default before it
161
+ * consults `required`, so the flag has no reachable effect on any of them.
162
+ *
163
+ * Returns an empty array - never `undefined` - when the operator declines or
164
+ * the session cannot ask, so the composing caller writes no key, no block
165
+ * and no stray comma. Touches no filesystem: `PluginInitIo` carries no `fs`,
166
+ * and writing the answers is the host's job on both paths.
167
+ */
168
+ async function askAnalyticsBlock(io) {
169
+ if (!io.isInteractive) {
170
+ io.logger.warn('analytics: not an interactive session - skipping the analytics block; run `blogwright analytics init` later, or write it by hand');
171
+ return [];
172
+ }
173
+ const defaults = validateAnalyticsConfig(undefined);
174
+ const enable = await io.ask({
175
+ prompt: 'set up analytics now? CloudFront access logs into an Iceberg table (y/n)',
176
+ defaultValue: 'y',
177
+ });
178
+ if (!isYes(enable))
179
+ return [];
180
+ const namespace = await io.ask({
181
+ prompt: 'Iceberg namespace holding the table',
182
+ defaultValue: defaults.namespace,
183
+ validate: (answer) => rejectionOf({ namespace: answer }),
184
+ });
185
+ const table = await io.ask({
186
+ prompt: 'Iceberg table the page views land in',
187
+ defaultValue: defaults.table,
188
+ validate: (answer) => rejectionOf({ table: answer }),
189
+ });
190
+ const bots = await io.ask({
191
+ prompt: 'bot traffic in dashboard queries - flag (keep, marked) or filter (excluded)',
192
+ defaultValue: defaults.bots,
193
+ validate: (answer) => rejectionOf({ bots: answer }),
194
+ });
195
+ const port = await io.ask({
196
+ prompt: 'port the local dashboard listens on',
197
+ defaultValue: String(defaults.dashboard.port),
198
+ validate: portRejection,
199
+ });
200
+ return [
201
+ { property: `"namespace": "${namespace}"`, comment: 'Iceberg namespace holding the table' },
202
+ { property: `"table": "${table}"`, comment: 'Iceberg table the page views land in' },
203
+ {
204
+ property: `"bots": "${bots}"`,
205
+ comment: 'flag keeps bot rows and marks them; filter excludes them from queries',
206
+ },
207
+ // The number is rendered from the parsed value, not the typed text: a
208
+ // `04317` an operator types validates fine as a number but is not legal
209
+ // JSON, and this block is re-parsed before it reaches disk.
210
+ {
211
+ property: `"dashboard": { "port": ${Number(port)} }`,
212
+ comment: 'the dashboard binds 127.0.0.1 on this port',
213
+ },
214
+ ];
215
+ }
216
+ /**
217
+ * The plugin the CLI discovers. `Plugin<AnalyticsConfig>` names the block
218
+ * `validateConfig` returns, which is what the host puts on
219
+ * `ctx.pluginConfig` - the only route to this plugin's settings, and (for
220
+ * the two environment-carrying ones) only through
221
+ * `resolveAnalyticsConfig(ctx)`.
222
+ */
223
+ const analyticsPlugin = {
224
+ name: ANALYTICS_NAMESPACE,
225
+ description: 'CloudFront access logs in an Iceberg table, with a local dashboard',
226
+ configKey: ANALYTICS_NAMESPACE,
227
+ validateConfig: validateAnalyticsConfig,
228
+ init: askAnalyticsBlock,
229
+ nodes: buildAnalyticsNodes,
230
+ commands: [
231
+ {
232
+ action: 'status',
233
+ summary: "show resources, stream delivery health and the table's row count",
234
+ run: status,
235
+ },
236
+ {
237
+ action: 'dashboard',
238
+ summary: 'serve the local dashboard over the table on 127.0.0.1',
239
+ run: dashboard,
240
+ },
241
+ {
242
+ action: 'backfill',
243
+ summary: "one-shot fill of pre-Firehose days from the site's logs",
244
+ run: backfill,
245
+ },
246
+ ],
247
+ };
248
+ export default analyticsPlugin;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Analytics-owned ports. Shared ports (`FileSystem`, `Terminal`) come from
3
+ * blogwright-core; the ports here serve only this package, following the CLI's
4
+ * private-ports precedent (`packages/cli/src/ports.ts:1-6`).
5
+ *
6
+ * **The vendor library lives only behind these interfaces.** DuckDB is what
7
+ * actually answers a named query - it attaches the S3 Tables catalog in
8
+ * read-only mode and runs the statement - and what writes a backfilled day,
9
+ * through a second attach of the same catalog that is not read-only. DuckDB's
10
+ * node-api package is imported nowhere in this package except the two adapters
11
+ * under `adapters/` that implement {@link AnalyticsQuery} and
12
+ * {@link AnalyticsIngest}. (Named in prose rather than spelled as the package
13
+ * specifier, so the definition of done's grep for that specifier over this
14
+ * tree does not trip over a comment.) Nothing in the signatures below names a
15
+ * DuckDB type either: a result row is this package's own {@link QueryRow} and
16
+ * an inserted row is {@link PageView} off `schema.ts`, neither of them a
17
+ * vendor object, so the named query set, the local server, the dashboard's
18
+ * data shaping and the backfill command all compile with no knowledge that
19
+ * DuckDB exists.
20
+ *
21
+ * That containment is load-bearing rather than tidy. The change spec records
22
+ * DuckDB's iceberg extension as *preview*, so its attach syntax may move, and
23
+ * it records that the "no Lake Formation grant" assumption holds only while
24
+ * the table bucket stays in IAM access-control mode. The port is what keeps
25
+ * either of those turning into an edit spread across the dashboard: both land
26
+ * in one adapter, which is also where DuckDB's errors are mapped into the
27
+ * repo's own vocabulary.
28
+ *
29
+ * Tests never start DuckDB. They substitute at these ports with the
30
+ * fixture-backed fake in `fixture-query.ts`, which answers the same named set
31
+ * through the same lookup and range validation the real adapter uses, and with
32
+ * the recording fake in `fixture-ingest.ts`, which keeps every day it was
33
+ * handed so a test can assert what was written rather than that something was.
34
+ */
35
+ import type { QueryName, QueryParams } from './queries.js';
36
+ import type { PageView } from './schema.js';
37
+ /**
38
+ * One cell of a result row. `null` is deliberately not among them: a SQL NULL
39
+ * reaches a caller as an absent key, so no reader ever has to tell "the column
40
+ * was null" from "the column means null" - the repo's rule that no `null`
41
+ * stands for a domain value, applied at the read boundary.
42
+ *
43
+ * Not exported: no consumer needs the bare union today - one can reach the
44
+ * same type as `QueryRow[string]`. Export it once a real consumer needs it by
45
+ * name.
46
+ */
47
+ type QueryValue = string | number | boolean;
48
+ /**
49
+ * One result row, keyed by the result column names the query's definition
50
+ * declares (`resultColumns` in `queries.ts`). A generic record rather than a
51
+ * per-query row type: the seven definitions' shapes are data, and a caller
52
+ * that wants a narrower type narrows it at its own boundary.
53
+ */
54
+ export type QueryRow = Readonly<Record<string, QueryValue>>;
55
+ /**
56
+ * Reading the `page_views` table, in domain vocabulary: one operation, "answer
57
+ * this named query over this date range".
58
+ *
59
+ * `name` is a {@link QueryName} and not a `string`, which is the port's half of
60
+ * the spec's *Named queries, never client-supplied SQL* decision - a caller
61
+ * cannot ask this port for a statement, only for one of the seven the package
62
+ * defines. An implementation still validates at run time, because the one seam
63
+ * that feeds it is an HTTP path off the local server, where the compiler's
64
+ * guarantee has already been erased: an unknown name raises an error listing
65
+ * the available names, and an absent or inverted range raises rather than
66
+ * quietly standing in a default (see `prepareQuery`).
67
+ */
68
+ export interface AnalyticsQuery {
69
+ /**
70
+ * Answer one of the named queries. Rows come back in the definition's own
71
+ * order (the `ORDER BY` it carries), so a caller never re-sorts to get a
72
+ * stable chart.
73
+ */
74
+ run(name: QueryName, params: QueryParams): Promise<readonly QueryRow[]>;
75
+ }
76
+ /**
77
+ * Writing whole days into the `page_views` table, in domain vocabulary: one
78
+ * operation, "insert these rows for this UTC day".
79
+ *
80
+ * **It exists for the one-shot `analytics backfill` action and nothing else.**
81
+ * The steady-state pipeline never reaches it: CloudFront's logs arrive through
82
+ * Firehose, which writes the table itself (§Analytics pipeline → Shape), so
83
+ * the only writer on this side of the port is the hand-run pull of history
84
+ * that predates the Firehose delivery. The dashboard's read path is never
85
+ * handed an implementation of this interface - `createDashboardServer` takes
86
+ * an {@link AnalyticsQuery} and has no member to put one in - so a named query
87
+ * cannot become a write however the server is called.
88
+ *
89
+ * `day` is the `YYYY-MM-DD` UTC day every row in `rows` carries in its own
90
+ * `day` column, passed separately because it is the unit of the operation: an
91
+ * implementation inserts the whole day or none of it. Passing it also lets an
92
+ * implementation refuse a batch whose rows do not all belong to the day it was
93
+ * asked to write, which is a mistake no row-shaped signature could express.
94
+ *
95
+ * There is deliberately no `close()`. Task 55 measured the read side - an
96
+ * unclosed in-memory DuckDB instance holds no libuv handle and the process
97
+ * exits - and the write side adds nothing that outlives a call: each
98
+ * {@link insertDay} is its own transaction, committed or rolled back before it
99
+ * returns, so a command that stops between days leaves no open transaction and
100
+ * no half-written day behind. A `close()` here would be an interface member
101
+ * with no caller, which `pnpm knip` cannot see and a reader would take for a
102
+ * resource that needs releasing.
103
+ */
104
+ export interface AnalyticsIngest {
105
+ /**
106
+ * Insert one whole UTC day's rows, atomically. An empty `rows` is a caller
107
+ * error rather than a no-op: a day with nothing to write is a day the caller
108
+ * should not have asked to insert, and treating it as success would make an
109
+ * "inserted" report line true of a day that got nothing.
110
+ */
111
+ insertDay(day: string, rows: readonly PageView[]): Promise<void>;
112
+ }
113
+ export {};
package/dist/ports.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Analytics-owned ports. Shared ports (`FileSystem`, `Terminal`) come from
3
+ * blogwright-core; the ports here serve only this package, following the CLI's
4
+ * private-ports precedent (`packages/cli/src/ports.ts:1-6`).
5
+ *
6
+ * **The vendor library lives only behind these interfaces.** DuckDB is what
7
+ * actually answers a named query - it attaches the S3 Tables catalog in
8
+ * read-only mode and runs the statement - and what writes a backfilled day,
9
+ * through a second attach of the same catalog that is not read-only. DuckDB's
10
+ * node-api package is imported nowhere in this package except the two adapters
11
+ * under `adapters/` that implement {@link AnalyticsQuery} and
12
+ * {@link AnalyticsIngest}. (Named in prose rather than spelled as the package
13
+ * specifier, so the definition of done's grep for that specifier over this
14
+ * tree does not trip over a comment.) Nothing in the signatures below names a
15
+ * DuckDB type either: a result row is this package's own {@link QueryRow} and
16
+ * an inserted row is {@link PageView} off `schema.ts`, neither of them a
17
+ * vendor object, so the named query set, the local server, the dashboard's
18
+ * data shaping and the backfill command all compile with no knowledge that
19
+ * DuckDB exists.
20
+ *
21
+ * That containment is load-bearing rather than tidy. The change spec records
22
+ * DuckDB's iceberg extension as *preview*, so its attach syntax may move, and
23
+ * it records that the "no Lake Formation grant" assumption holds only while
24
+ * the table bucket stays in IAM access-control mode. The port is what keeps
25
+ * either of those turning into an edit spread across the dashboard: both land
26
+ * in one adapter, which is also where DuckDB's errors are mapped into the
27
+ * repo's own vocabulary.
28
+ *
29
+ * Tests never start DuckDB. They substitute at these ports with the
30
+ * fixture-backed fake in `fixture-query.ts`, which answers the same named set
31
+ * through the same lookup and range validation the real adapter uses, and with
32
+ * the recording fake in `fixture-ingest.ts`, which keeps every day it was
33
+ * handed so a test can assert what was written rather than that something was.
34
+ */
35
+ export {};