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,124 @@
1
+ /**
2
+ * The bodies behind the three actions `plugin.ts` declares, and **the
3
+ * plugin's composition root**. The command TABLE is written once, in
4
+ * `plugin.ts`, and no later task edits it; each action's behaviour lands here
5
+ * instead - `status` and `dashboard` below in full, and `backfill` as the
6
+ * three lines that construct its two adapters, its logic living in
7
+ * `backfill.ts`. All three are dispatchable from the moment the manifest field
8
+ * makes this package discoverable.
9
+ *
10
+ * A body takes exactly the parameters it needs. `PluginCommand.run(ctx, args)`
11
+ * accepts a narrower function - a zero-argument function is assignable to it,
12
+ * and so is one taking a `Pick` of `PluginContext` - so filling a body in
13
+ * never changes the table in `plugin.ts`.
14
+ *
15
+ * **Composition root** means one thing concretely: this is the only module in
16
+ * the package that constructs a DuckDB adapter and the only one that resolves
17
+ * credentials, exactly as `packages/cli/src/context.ts` is for the CLI's own
18
+ * adapters. Every module below it - the server, the named query set, the data
19
+ * shaping, the backfill - is handed a *port* and can name no vendor at all.
20
+ * All three commands that reach the table construct their adapters on their
21
+ * own line here: `dashboard` hands one to the server it starts, and `status`
22
+ * and `backfill` take theirs as defaulted parameters, which is what lets a
23
+ * test drive the whole command over a fake without patching a module.
24
+ *
25
+ * `backfill` is the only one that constructs two of them, and they are not
26
+ * interchangeable: its occupancy count crosses `AnalyticsQuery`, whose attach
27
+ * is read-only, and its insert crosses `AnalyticsIngest`, whose attach is not.
28
+ * One writable session answering both would be shorter and would put the
29
+ * dashboard's own read path one refactor away from a connection that can
30
+ * write.
31
+ *
32
+ * `bootstrap` and `destroy` are deliberately absent from this module as well
33
+ * as from the table: they are always the CLI's generic lifecycle verbs, run
34
+ * by an engine a plugin may not import - see `plugin.ts`'s own comment.
35
+ */
36
+ import { type PluginContext } from 'blogwright-core';
37
+ import { type BackfillPorts } from './backfill.js';
38
+ import { type AnalyticsConfig } from './config.js';
39
+ import type { AnalyticsQuery } from './ports.js';
40
+ /**
41
+ * `analytics status`: the plugin's own nodes read against
42
+ * `state/<env>.analytics.json`, plus the Firehose stream's delivery health
43
+ * and the table's current row count. Declared rather than left to the
44
+ * generic `status` verb because it does strictly more than that verb does
45
+ * (§Analytics plugin → Namespace and commands), which task 16's precedence
46
+ * permits: only `bootstrap` and `destroy` are reserved.
47
+ *
48
+ * **It takes the whole `PluginContext`, not a `Pick` of it.** Every other
49
+ * consumer in this package narrows (`DashboardCommandContext`,
50
+ * `DuckDbSessionContext`, `AnalyticsConfigContext`), and this one cannot: it
51
+ * hands `ctx` to `read()` on each of the plugin's own nodes, and a node is a
52
+ * `ResourceNode<PluginContext<AnalyticsConfig>>`, so the narrowest type that
53
+ * type-checks is the SPI context entire. A `Pick` listing its fifteen required members
54
+ * would be that type with a second name.
55
+ *
56
+ * `args` is accepted and unused: the host calls `run(ctx, args)` for every
57
+ * declared command, and this one takes no arguments of its own - the
58
+ * environment is a positional the dispatcher has already consumed, and
59
+ * `--plain` reaches this command as `ctx.ports.terminal.isInteractive` rather
60
+ * than as a flag to parse.
61
+ *
62
+ * `query` is a defaulted parameter for the reason the site's own
63
+ * `status(ctx, nodes = buildNodes(ctx))` takes its node set that way: every
64
+ * real call site passes nothing and gets the composition root's adapter, and a
65
+ * test hands it the fixture-backed fake instead of patching a module. The
66
+ * default constructs the adapter but does not connect - the connection and the
67
+ * credentials are resolved inside the first query - so a status that never
68
+ * reaches the table starts nothing.
69
+ */
70
+ export declare function status(ctx: PluginContext<AnalyticsConfig>, _args?: string[], query?: AnalyticsQuery): Promise<void>;
71
+ /**
72
+ * The slice of a plugin context {@link dashboard} reads, taken as a `Pick` of
73
+ * core's own `PluginContext` rather than a restatement of it, the way
74
+ * `DuckDbSessionContext` and `AnalyticsConfigContext` already are: the members
75
+ * cannot drift from the SPI, any `PluginContext<AnalyticsConfig>` satisfies
76
+ * it, and a test builds the six it needs instead of the SPI's sixteen.
77
+ */
78
+ export type DashboardCommandContext = Pick<PluginContext<AnalyticsConfig>, 'env' | 'config' | 'pluginConfig' | 'accountId' | 'ports' | 'logger'>;
79
+ /**
80
+ * `analytics dashboard`: serve the local dashboard over the Iceberg table,
81
+ * bound to `127.0.0.1` on the resolved `dashboard.port`.
82
+ *
83
+ * This is the composition root the module comment describes, and the three
84
+ * lines that make it one are the adapter construction, the credential
85
+ * resolution and the `appDir`. **Credentials are core's own provider chain**
86
+ * (`createCredentialProvider`, `packages/core/src/aws/credentials.ts`),
87
+ * resolved here and handed to the adapter, which is the spec's §Analytics
88
+ * dashboard → Credentials: one credential source serves the whole CLI, so a
89
+ * session that works for `deploy` works for the dashboard. It is built with no
90
+ * `override`, matching `transform/entry.ts`: that flag substitutes an
91
+ * emulator's dummy `test`/`test` pair when a real chain fails, and the
92
+ * dashboard has no emulator to talk to - the adapter attaches a real S3 Tables
93
+ * ARN with no endpoint override at all, so dummy credentials would turn "you
94
+ * are not logged in" into an opaque authorisation failure from AWS.
95
+ *
96
+ * Constructing the adapter touches neither the network nor the native library
97
+ * - it opens its connection lazily on the first query - so the listener is
98
+ * bound and the URL is printed before AWS is ever consulted.
99
+ *
100
+ * The `finally` is the shutdown path, and it covers both ways out: the signal
101
+ * {@link untilStopped} waits for, and a failure raised while waiting. Either
102
+ * way `close()` is awaited, so the port is released before this function
103
+ * returns and the next `analytics dashboard` binds it again.
104
+ */
105
+ export declare function dashboard(ctx: DashboardCommandContext): Promise<void>;
106
+ /**
107
+ * `analytics backfill`: the optional, one-shot, idempotent pull of history
108
+ * that predates the Firehose delivery, from the site's CloudWatch log group
109
+ * into the table. Never part of the steady-state pipeline. What it does is
110
+ * `backfill.ts`'s; what this line owns is the two adapters it does it through.
111
+ *
112
+ * `ports` is a defaulted parameter for the reason `status`'s `query` is: every
113
+ * real call site passes nothing and gets the composition root's adapters, and
114
+ * a test hands it a recording pair instead of patching a module. Constructing
115
+ * either adapter touches neither the network nor the native library - both
116
+ * open their connection inside the first statement - so the command's refusal
117
+ * when the plugin's state carries no delivery bound still happens before
118
+ * anything is opened.
119
+ *
120
+ * `args` is accepted and unused: the host calls `run(ctx, args)` for every
121
+ * declared command and this one takes no arguments of its own - the
122
+ * environment is a positional the dispatcher has already consumed.
123
+ */
124
+ export declare function backfill(ctx: PluginContext<AnalyticsConfig>, _args?: string[], ports?: BackfillPorts): Promise<void>;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * The bodies behind the three actions `plugin.ts` declares, and **the
3
+ * plugin's composition root**. The command TABLE is written once, in
4
+ * `plugin.ts`, and no later task edits it; each action's behaviour lands here
5
+ * instead - `status` and `dashboard` below in full, and `backfill` as the
6
+ * three lines that construct its two adapters, its logic living in
7
+ * `backfill.ts`. All three are dispatchable from the moment the manifest field
8
+ * makes this package discoverable.
9
+ *
10
+ * A body takes exactly the parameters it needs. `PluginCommand.run(ctx, args)`
11
+ * accepts a narrower function - a zero-argument function is assignable to it,
12
+ * and so is one taking a `Pick` of `PluginContext` - so filling a body in
13
+ * never changes the table in `plugin.ts`.
14
+ *
15
+ * **Composition root** means one thing concretely: this is the only module in
16
+ * the package that constructs a DuckDB adapter and the only one that resolves
17
+ * credentials, exactly as `packages/cli/src/context.ts` is for the CLI's own
18
+ * adapters. Every module below it - the server, the named query set, the data
19
+ * shaping, the backfill - is handed a *port* and can name no vendor at all.
20
+ * All three commands that reach the table construct their adapters on their
21
+ * own line here: `dashboard` hands one to the server it starts, and `status`
22
+ * and `backfill` take theirs as defaulted parameters, which is what lets a
23
+ * test drive the whole command over a fake without patching a module.
24
+ *
25
+ * `backfill` is the only one that constructs two of them, and they are not
26
+ * interchangeable: its occupancy count crosses `AnalyticsQuery`, whose attach
27
+ * is read-only, and its insert crosses `AnalyticsIngest`, whose attach is not.
28
+ * One writable session answering both would be shorter and would put the
29
+ * dashboard's own read path one refactor away from a connection that can
30
+ * write.
31
+ *
32
+ * `bootstrap` and `destroy` are deliberately absent from this module as well
33
+ * as from the table: they are always the CLI's generic lifecycle verbs, run
34
+ * by an engine a plugin may not import - see `plugin.ts`'s own comment.
35
+ */
36
+ import { fileURLToPath } from 'node:url';
37
+ import { colors, createCredentialProvider } from 'blogwright-core';
38
+ import { createDuckDbAnalyticsIngest } from './adapters/duckdb-ingest.js';
39
+ import { createDuckDbAnalyticsQuery } from './adapters/duckdb-query.js';
40
+ import { runBackfill } from './backfill.js';
41
+ import { resolveAnalyticsConfig } from './config.js';
42
+ import { buildAnalyticsNodes, FIREHOSE_STREAM_NODE } from './nodes.js';
43
+ import { ROW_COUNT_COLUMN, ROW_COUNT_QUERY, WHOLE_TABLE_RANGE } from './queries.js';
44
+ import { createDashboardServer } from './server.js';
45
+ /**
46
+ * The signals that stop a foreground command. `SIGTERM` joins `SIGINT`
47
+ * because a dashboard is as likely to be stopped by a supervisor or a
48
+ * container runtime as by an operator's Ctrl+C, and both must release the
49
+ * listener rather than leave the port held by a half-dead process.
50
+ */
51
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM'];
52
+ /** The tree glyphs the pretty form marks each state with - the CLI's own. */
53
+ const STATUS_MARKS = {
54
+ present: colors.green('✓'),
55
+ missing: colors.yellow('◌'),
56
+ error: colors.red('✗'),
57
+ };
58
+ /**
59
+ * The one {@link DeliveryState} that means records are reaching the table.
60
+ * Typed against the client's own union rather than spelled as a bare string,
61
+ * so renaming a state there is a compile error here instead of a status line
62
+ * that silently never reports healthy again.
63
+ */
64
+ const HEALTHY_DELIVERY_STATE = 'active';
65
+ /**
66
+ * Read every node the plugin contributes against the live account, in the
67
+ * order {@link buildAnalyticsNodes} returns them (no topological sort - a
68
+ * status is a read, not a reconcile).
69
+ *
70
+ * A `read()` that throws becomes an `error` entry rather than ending the
71
+ * listing: one unreadable item must not take down the whole listing, which is
72
+ * `history`'s manifest loop in `packages/cli/src/commands.ts` and, for exactly
73
+ * this shape, the CLI's own `readNodeStatus`. Nothing is saved - `read()`
74
+ * hydrates `ctx.state` in memory and this command never calls `ctx.save()`, so
75
+ * a status can neither create nor rewrite `state/<env>.analytics.json`.
76
+ */
77
+ async function readNodeEntries(ctx) {
78
+ const entries = [];
79
+ for (const node of buildAnalyticsNodes()) {
80
+ try {
81
+ const exists = await node.read(ctx);
82
+ entries.push({ id: node.id, title: node.title, state: exists ? 'present' : 'missing' });
83
+ }
84
+ catch (err) {
85
+ entries.push({
86
+ id: node.id,
87
+ title: node.title,
88
+ state: 'error',
89
+ detail: err.message,
90
+ });
91
+ }
92
+ }
93
+ return entries;
94
+ }
95
+ /**
96
+ * Report the node listing: the drift tree on an interactive terminal, one
97
+ * stable line per node otherwise. The same split, the same marks and the same
98
+ * `read failed` wording as the site's own `status`
99
+ * (`packages/cli/src/commands.ts`, through `logStatusEntries` in its
100
+ * `render.ts`), restated here because a plugin may not import that module.
101
+ *
102
+ * One deliberate difference in the plain form: the CLI appends
103
+ * `JSON.stringify` of the node's recorded outputs to each line. This command
104
+ * does not. The plain form is the contract CI and agents read, and a line
105
+ * carrying an ARN carries the account id, the environment and a
106
+ * service-generated table id with it - so the "same" line would differ between
107
+ * two environments of the same site and could never be asserted as a contract.
108
+ * What the outputs say is in `state/<env>.analytics.json`, and the two lines
109
+ * this command adds after the listing are what a reader wants them for.
110
+ */
111
+ function logNodeEntries(entries, pretty, logger) {
112
+ if (pretty) {
113
+ entries.forEach((entry, index) => {
114
+ const connector = index === entries.length - 1 ? '╰─' : '├─';
115
+ const detail = entry.detail === undefined ? '' : ` ${colors.dim(entry.detail)}`;
116
+ logger.info(`${connector} ${STATUS_MARKS[entry.state]} ${entry.title}${detail}`);
117
+ });
118
+ return;
119
+ }
120
+ // The plain form is the stable contract for CI logs and agents.
121
+ for (const entry of entries) {
122
+ if (entry.state === 'error') {
123
+ logger.warn(`${entry.title}: read failed (${entry.detail})`);
124
+ continue;
125
+ }
126
+ const mark = entry.state === 'present' ? colors.green('present') : colors.yellow('missing');
127
+ logger.info(` ${mark} ${entry.title}`);
128
+ }
129
+ }
130
+ /**
131
+ * Report the delivery stream's health from the state its own `read` hydrated a
132
+ * moment ago (task 51's `recordStream` puts the stream's `state` and, when the
133
+ * service reports one, its `failure` into the plugin's scoped state). No
134
+ * second describe is issued: the node has just made that call, and a status
135
+ * that made it twice would report two different answers on a stream that
136
+ * changed in between.
137
+ *
138
+ * A stream that is absent is reported as a warning rather than as health, and
139
+ * that choice is worth stating because the definition of done does not pin it:
140
+ * "no stream" and "a stream in `create-failed`" are the same operational fact
141
+ * - nothing is being delivered - and the listing above already says which node
142
+ * is missing, so a health line that stayed silent would read as healthy.
143
+ */
144
+ function logStreamHealth(ctx, entries) {
145
+ const entry = entries.find((candidate) => candidate.id === FIREHOSE_STREAM_NODE);
146
+ if (entry?.state === 'error') {
147
+ ctx.logger.warn(`Firehose delivery: unavailable - reading the stream failed (${entry.detail})`);
148
+ return;
149
+ }
150
+ if (entry?.state !== 'present') {
151
+ ctx.logger.warn(`Firehose delivery: no delivery stream - \`blogwright analytics bootstrap ${ctx.env}\` creates it`);
152
+ return;
153
+ }
154
+ const recorded = ctx.state.resources[FIREHOSE_STREAM_NODE];
155
+ const state = typeof recorded?.['state'] === 'string' ? recorded['state'] : 'unrecorded';
156
+ const failure = typeof recorded?.['failure'] === 'string' ? ` - ${recorded['failure']}` : '';
157
+ if (state === HEALTHY_DELIVERY_STATE) {
158
+ ctx.logger.info(` Firehose delivery: ${state}`);
159
+ return;
160
+ }
161
+ ctx.logger.warn(`Firehose delivery: ${state}${failure}`);
162
+ }
163
+ /**
164
+ * Report the table's current row count, taken through the `AnalyticsQuery`
165
+ * port by name. The command writes no SQL: `ROW_COUNT_QUERY` names one of the
166
+ * definitions in `queries.ts`, and {@link WHOLE_TABLE_RANGE} is what "current
167
+ * row count" means for a set whose every definition is bounded on the `day`
168
+ * partition. `includeBots` is bound explicitly rather than left to
169
+ * `config.analytics.bots`, because this is the table's row count and not a
170
+ * dashboard figure - a bot row is still a row.
171
+ *
172
+ * A failed read degrades to a warning, which is the whole point of doing it
173
+ * last: the table is the one part of this listing that needs credentials the
174
+ * node reads do not (the vendor library attaches the catalog itself), so an
175
+ * operator with no session still gets all twelve nodes and the stream's health.
176
+ */
177
+ async function logRowCount(ctx, query) {
178
+ const config = resolveAnalyticsConfig(ctx);
179
+ const relation = `${config.namespace}.${config.table}`;
180
+ try {
181
+ const rows = await query.run(ROW_COUNT_QUERY, {
182
+ range: WHOLE_TABLE_RANGE,
183
+ includeBots: true,
184
+ });
185
+ const count = rows[0]?.[ROW_COUNT_COLUMN];
186
+ if (typeof count !== 'number') {
187
+ throw new Error(`the ${ROW_COUNT_QUERY} query answered no ${ROW_COUNT_COLUMN}`);
188
+ }
189
+ ctx.logger.info(` rows in ${relation}: ${count}`);
190
+ }
191
+ catch (err) {
192
+ ctx.logger.warn(`rows in ${relation}: unavailable - ${err.message}`);
193
+ }
194
+ }
195
+ /**
196
+ * `analytics status`: the plugin's own nodes read against
197
+ * `state/<env>.analytics.json`, plus the Firehose stream's delivery health
198
+ * and the table's current row count. Declared rather than left to the
199
+ * generic `status` verb because it does strictly more than that verb does
200
+ * (§Analytics plugin → Namespace and commands), which task 16's precedence
201
+ * permits: only `bootstrap` and `destroy` are reserved.
202
+ *
203
+ * **It takes the whole `PluginContext`, not a `Pick` of it.** Every other
204
+ * consumer in this package narrows (`DashboardCommandContext`,
205
+ * `DuckDbSessionContext`, `AnalyticsConfigContext`), and this one cannot: it
206
+ * hands `ctx` to `read()` on each of the plugin's own nodes, and a node is a
207
+ * `ResourceNode<PluginContext<AnalyticsConfig>>`, so the narrowest type that
208
+ * type-checks is the SPI context entire. A `Pick` listing its fifteen required members
209
+ * would be that type with a second name.
210
+ *
211
+ * `args` is accepted and unused: the host calls `run(ctx, args)` for every
212
+ * declared command, and this one takes no arguments of its own - the
213
+ * environment is a positional the dispatcher has already consumed, and
214
+ * `--plain` reaches this command as `ctx.ports.terminal.isInteractive` rather
215
+ * than as a flag to parse.
216
+ *
217
+ * `query` is a defaulted parameter for the reason the site's own
218
+ * `status(ctx, nodes = buildNodes(ctx))` takes its node set that way: every
219
+ * real call site passes nothing and gets the composition root's adapter, and a
220
+ * test hands it the fixture-backed fake instead of patching a module. The
221
+ * default constructs the adapter but does not connect - the connection and the
222
+ * credentials are resolved inside the first query - so a status that never
223
+ * reaches the table starts nothing.
224
+ */
225
+ export async function status(ctx, _args = [], query = createDuckDbAnalyticsQuery({
226
+ ctx,
227
+ credentials: createCredentialProvider({}),
228
+ })) {
229
+ ctx.logger.info(colors.bold(`Analytics status for "${ctx.env}" (bucket ${ctx.names.bucket})`));
230
+ const entries = await readNodeEntries(ctx);
231
+ logNodeEntries(entries, ctx.ports.terminal.isInteractive, ctx.logger);
232
+ logStreamHealth(ctx, entries);
233
+ await logRowCount(ctx, query);
234
+ }
235
+ /**
236
+ * The prebuilt dashboard application task 57 emits, beside this module's own
237
+ * compiled output (`dist/commands.js` → `dist/app`). Located from
238
+ * `import.meta.url` for the reason `cliPackageDir` (`packages/cli/src/context.ts`)
239
+ * is: a package's own files are not reachable through its `exports` map, so
240
+ * self-location is a composition-root concern and not something the server -
241
+ * which is handed the resolved directory as data - can derive. Under the test
242
+ * runner it resolves beside the sources instead, where no application is
243
+ * built, and the server answers a 503 naming the directory.
244
+ */
245
+ function dashboardAppDir() {
246
+ return fileURLToPath(new URL('app', import.meta.url));
247
+ }
248
+ /**
249
+ * Resolve once a stop signal arrives, un-registering both listeners first so
250
+ * a finished command leaves the process exactly as it found it. Resolves with
251
+ * the signal, so the line the operator sees says which one stopped it.
252
+ */
253
+ function untilStopped() {
254
+ return new Promise((resolve) => {
255
+ const listeners = new Map();
256
+ const stop = (signal) => {
257
+ for (const [name, listener] of listeners)
258
+ process.off(name, listener);
259
+ resolve(signal);
260
+ };
261
+ for (const signal of STOP_SIGNALS) {
262
+ const listener = () => stop(signal);
263
+ listeners.set(signal, listener);
264
+ process.on(signal, listener);
265
+ }
266
+ });
267
+ }
268
+ /**
269
+ * `analytics dashboard`: serve the local dashboard over the Iceberg table,
270
+ * bound to `127.0.0.1` on the resolved `dashboard.port`.
271
+ *
272
+ * This is the composition root the module comment describes, and the three
273
+ * lines that make it one are the adapter construction, the credential
274
+ * resolution and the `appDir`. **Credentials are core's own provider chain**
275
+ * (`createCredentialProvider`, `packages/core/src/aws/credentials.ts`),
276
+ * resolved here and handed to the adapter, which is the spec's §Analytics
277
+ * dashboard → Credentials: one credential source serves the whole CLI, so a
278
+ * session that works for `deploy` works for the dashboard. It is built with no
279
+ * `override`, matching `transform/entry.ts`: that flag substitutes an
280
+ * emulator's dummy `test`/`test` pair when a real chain fails, and the
281
+ * dashboard has no emulator to talk to - the adapter attaches a real S3 Tables
282
+ * ARN with no endpoint override at all, so dummy credentials would turn "you
283
+ * are not logged in" into an opaque authorisation failure from AWS.
284
+ *
285
+ * Constructing the adapter touches neither the network nor the native library
286
+ * - it opens its connection lazily on the first query - so the listener is
287
+ * bound and the URL is printed before AWS is ever consulted.
288
+ *
289
+ * The `finally` is the shutdown path, and it covers both ways out: the signal
290
+ * {@link untilStopped} waits for, and a failure raised while waiting. Either
291
+ * way `close()` is awaited, so the port is released before this function
292
+ * returns and the next `analytics dashboard` binds it again.
293
+ */
294
+ export async function dashboard(ctx) {
295
+ const config = resolveAnalyticsConfig(ctx);
296
+ const server = await createDashboardServer({
297
+ query: createDuckDbAnalyticsQuery({ ctx, credentials: createCredentialProvider({}) }),
298
+ config: ctx.pluginConfig,
299
+ port: config.dashboard.port,
300
+ appDir: dashboardAppDir(),
301
+ fs: ctx.ports.fs,
302
+ });
303
+ ctx.logger.info(`analytics dashboard on ${server.url} - press Ctrl+C to stop`);
304
+ try {
305
+ const signal = await untilStopped();
306
+ ctx.logger.info(`${signal} received - stopping the analytics dashboard`);
307
+ }
308
+ finally {
309
+ await server.close();
310
+ ctx.logger.ok(`analytics dashboard stopped; port ${server.address.port} released`);
311
+ }
312
+ }
313
+ /**
314
+ * `analytics backfill`: the optional, one-shot, idempotent pull of history
315
+ * that predates the Firehose delivery, from the site's CloudWatch log group
316
+ * into the table. Never part of the steady-state pipeline. What it does is
317
+ * `backfill.ts`'s; what this line owns is the two adapters it does it through.
318
+ *
319
+ * `ports` is a defaulted parameter for the reason `status`'s `query` is: every
320
+ * real call site passes nothing and gets the composition root's adapters, and
321
+ * a test hands it a recording pair instead of patching a module. Constructing
322
+ * either adapter touches neither the network nor the native library - both
323
+ * open their connection inside the first statement - so the command's refusal
324
+ * when the plugin's state carries no delivery bound still happens before
325
+ * anything is opened.
326
+ *
327
+ * `args` is accepted and unused: the host calls `run(ctx, args)` for every
328
+ * declared command and this one takes no arguments of its own - the
329
+ * environment is a positional the dispatcher has already consumed.
330
+ */
331
+ export async function backfill(ctx, _args = [], ports = {
332
+ query: createDuckDbAnalyticsQuery({ ctx, credentials: createCredentialProvider({}) }),
333
+ ingest: createDuckDbAnalyticsIngest({ ctx, credentials: createCredentialProvider({}) }),
334
+ }) {
335
+ await runBackfill(ctx, ports);
336
+ }
@@ -0,0 +1,162 @@
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
+ import type { PluginContext } from 'blogwright-core';
52
+ /**
53
+ * Whether bot traffic is excluded from dashboard queries. Records are stored
54
+ * either way. Not exported: no consumer needs the bare union today - one can
55
+ * reach the same type as `AnalyticsConfig['bots']`. Export it once a real
56
+ * consumer needs the union by name.
57
+ */
58
+ type BotHandling = 'flag' | 'filter';
59
+ /**
60
+ * The settings whose defaults are plain literals - the ones needing no
61
+ * environment to resolve, and so total on both the validated block and the
62
+ * resolved config.
63
+ */
64
+ interface EnvIndependentSettings {
65
+ /** Iceberg namespace holding the table. */
66
+ namespace: string;
67
+ /** Iceberg table name. */
68
+ table: string;
69
+ /** Bot handling for dashboard queries. */
70
+ bots: BotHandling;
71
+ /** Local dashboard settings. */
72
+ dashboard: {
73
+ port: number;
74
+ };
75
+ }
76
+ /**
77
+ * The two settings whose defaults carry the environment. Never readable off a
78
+ * validated block - see {@link ENV_DERIVED}.
79
+ */
80
+ interface EnvDerivedOverrides {
81
+ tableBucket?: string | undefined;
82
+ saltSecretName?: string | undefined;
83
+ }
84
+ /**
85
+ * The key an operator's `tableBucket`/`saltSecretName` overrides ride on. A
86
+ * module-private `unique symbol`: TypeScript emits it into `config.d.ts`
87
+ * unexported, so no module outside this one can name it, and neither
88
+ * `ctx.pluginConfig.tableBucket` nor `ctx.pluginConfig[ENV_DERIVED]` compiles
89
+ * anywhere else. That is the point - see this module's doc comment for the
90
+ * `?? <env-less fallback>` line it exists to make unwritable.
91
+ */
92
+ declare const ENV_DERIVED: unique symbol;
93
+ /**
94
+ * A validated `analytics` block: what {@link validateAnalyticsConfig} returns,
95
+ * and therefore what the host puts on `ctx.pluginConfig` for this plugin. The
96
+ * four settings whose defaults are literals are already applied and total; the
97
+ * two that need the environment are sealed under {@link ENV_DERIVED} and reach
98
+ * a reader only through {@link resolveAnalyticsConfig}.
99
+ */
100
+ export interface AnalyticsConfig extends EnvIndependentSettings {
101
+ readonly [ENV_DERIVED]: EnvDerivedOverrides;
102
+ }
103
+ /**
104
+ * An `analytics` block with every default applied, the environment-carrying
105
+ * ones included: the shape every reader downstream keeps a total type on, so
106
+ * no call site re-applies a default or re-checks for `undefined`.
107
+ */
108
+ export interface ResolvedAnalyticsConfig extends EnvIndependentSettings {
109
+ tableBucket: string;
110
+ saltSecretName: string;
111
+ }
112
+ /**
113
+ * Default port for the local dashboard server. Exported because the dashboard
114
+ * server binds this exact number: it imports the constant rather than
115
+ * restating it, so the default has one home.
116
+ */
117
+ export declare const DEFAULT_DASHBOARD_PORT = 4317;
118
+ /**
119
+ * The slice of a plugin context {@link resolveAnalyticsConfig} reads, taken as
120
+ * a `Pick` of core's own `PluginContext` rather than a restatement of it, so
121
+ * the three members cannot drift from the SPI. Any
122
+ * `PluginContext<AnalyticsConfig>` satisfies it, so a node passes `ctx`
123
+ * straight through. Not exported: a caller passes its context and never names
124
+ * this type.
125
+ */
126
+ type AnalyticsConfigContext = Pick<PluginContext<AnalyticsConfig>, 'env' | 'config' | 'pluginConfig'>;
127
+ /**
128
+ * Validate a raw `analytics` config block, boundary-checked as `unknown`
129
+ * because it comes off `parseConfigDocument`'s `raw` half (a plugin's block
130
+ * has no type until its own package narrows it). An absent block validates as
131
+ * an empty one, so installing the plugin without writing an `analytics` key is
132
+ * valid.
133
+ *
134
+ * Applies the four literal defaults, so `namespace`, `table`, `bots` and
135
+ * `dashboard.port` are total on the returned block. `tableBucket` and
136
+ * `saltSecretName` are validated here too, but sealed under
137
+ * {@link ENV_DERIVED}: their defaults carry the environment, which this
138
+ * signature does not, so {@link resolveAnalyticsConfig} is where they resolve.
139
+ *
140
+ * Raises in the repo's vocabulary (`packages/core/src/config.ts:274-340`),
141
+ * naming the offending key and value.
142
+ */
143
+ export declare function validateAnalyticsConfig(raw: unknown): AnalyticsConfig;
144
+ /**
145
+ * Resolve the environment-carrying settings a validated block sealed, giving
146
+ * the total config every reader downstream holds. Takes the plugin context
147
+ * rather than a `{ env, siteName }` pair, so the site identity is read out of
148
+ * `ctx` in one place instead of at each call site and no caller can supply an
149
+ * environment other than the one it is running in.
150
+ *
151
+ * Raises when handed a block the validator did not produce - see
152
+ * {@link unsealEnvDerivedOverrides}.
153
+ *
154
+ * The derived bucket name is length-checked here, where it is derived, exactly
155
+ * as `deriveNames` checks the site bucket it derives
156
+ * (`packages/core/src/config.ts:355`). Only the length is checked: `env` and
157
+ * `siteName` are already held to `^[a-z0-9-]+$` by `deriveNames` and
158
+ * `validateConfig`, and a second character check here would be a second home
159
+ * for a rule core already owns.
160
+ */
161
+ export declare function resolveAnalyticsConfig(ctx: AnalyticsConfigContext): ResolvedAnalyticsConfig;
162
+ export {};