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,365 @@
1
+ import { dirname, join, relative, resolve, sep } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { createHash } from "node:crypto";
5
+ //#region ../core/dist/ports.js
6
+ /**
7
+ * Repo-owned ports shared across packages. Domain code depends on these
8
+ * interfaces; adapters (see `adapters/`) implement them against real
9
+ * infrastructure and are constructed only at a composition root.
10
+ */
11
+ /** Raised by {@link FileSystem} implementations when a path has no file. */
12
+ var FileNotFoundError = class extends Error {
13
+ path;
14
+ constructor(path) {
15
+ super(`file not found: ${path}`);
16
+ this.name = "FileNotFoundError";
17
+ this.path = path;
18
+ }
19
+ };
20
+ //#endregion
21
+ //#region ../core/dist/adapters/node-fs.js
22
+ /** Node adapter for the FileSystem port (real disk I/O via node:fs). */
23
+ function isAbsence(err) {
24
+ const code = err.code;
25
+ return code === "ENOENT" || code === "ENOTDIR";
26
+ }
27
+ function contextualise(operation, path, err) {
28
+ return new Error(`${operation} ${path} failed: ${err.message}`, { cause: err });
29
+ }
30
+ /** Build the real-disk FileSystem adapter. */
31
+ function createNodeFileSystem() {
32
+ return {
33
+ async readText(path) {
34
+ try {
35
+ return await readFile(path, "utf8");
36
+ } catch (err) {
37
+ throw isAbsence(err) ? new FileNotFoundError(path) : contextualise("reading", path, err);
38
+ }
39
+ },
40
+ async readBytes(path) {
41
+ try {
42
+ return await readFile(path);
43
+ } catch (err) {
44
+ throw isAbsence(err) ? new FileNotFoundError(path) : contextualise("reading", path, err);
45
+ }
46
+ },
47
+ async writeText(path, text) {
48
+ try {
49
+ await mkdir(dirname(path), { recursive: true });
50
+ await writeFile(path, text);
51
+ } catch (err) {
52
+ throw contextualise("writing", path, err);
53
+ }
54
+ },
55
+ async exists(path) {
56
+ try {
57
+ await stat(path);
58
+ return true;
59
+ } catch (err) {
60
+ if (isAbsence(err)) return false;
61
+ throw contextualise("checking", path, err);
62
+ }
63
+ },
64
+ async listFiles(dir) {
65
+ try {
66
+ return (await readdir(dir, {
67
+ recursive: true,
68
+ withFileTypes: true
69
+ })).filter((entry) => entry.isFile()).map((entry) => relative(dir, join(entry.parentPath, entry.name))).sort();
70
+ } catch (err) {
71
+ throw isAbsence(err) ? new FileNotFoundError(dir) : contextualise("listing", dir, err);
72
+ }
73
+ }
74
+ };
75
+ }
76
+ //#endregion
77
+ //#region src/transform/handler.ts
78
+ /**
79
+ * The environment variable naming the Secrets Manager secret behind
80
+ * `visitor_key`, set on the function by the `analytics-transform-function`
81
+ * node (task 50). The *name* travels in the environment; the value never does,
82
+ * so it cannot be read off the function's configuration.
83
+ */
84
+ const SALT_SECRET_NAME_ENV = "ANALYTICS_SALT_SECRET_NAME";
85
+ //#endregion
86
+ //#region src/transform-hash.ts
87
+ /**
88
+ * The transform Lambda's build identity: a reproducible hash of the *source*
89
+ * the function is bundled from, the artifact names that hash is stamped
90
+ * beside, and the one derivation of the zip key that hash produces.
91
+ *
92
+ * This is step 5 of
93
+ * [§Implementation notes](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md) -
94
+ * "The hash keys the uploaded zip so identical source never redeploys the
95
+ * function" - and it exists as its own module so `nodes.ts` (task 50) consumes
96
+ * a derivation rather than restating a key format. A key spelled twice is a key
97
+ * that can disagree with itself, and the disagreement is invisible: the
98
+ * function silently redeploys on every reconcile, or worse, never redeploys
99
+ * after a real source change.
100
+ *
101
+ * ## Why the *source*, never the bundle
102
+ *
103
+ * `DEVELOPMENT.md` §Repository hygiene states the rule this module obeys: "The
104
+ * build-agent manifest hashes the agent's *source*, not the built bundle, so
105
+ * image keys do not vary by platform." Bundler output varies with the
106
+ * toolchain and the host (macOS laptop vs the Linux CI runner); source bytes do
107
+ * not. Hashing the bundle would key the same code under two different zip keys
108
+ * depending on who ran `pnpm build`, redeploying the function on every
109
+ * platform switch for no change at all. So the bundle path appears nowhere in
110
+ * the input list below - which is also why `packages/analytics/src/aws/lambda.ts`
111
+ * (task 36) deliberately does not surface `CodeSha256`: that digests the zip
112
+ * Lambda holds, and comparing it to this hash would compare two different
113
+ * things.
114
+ *
115
+ * ## What is in the hash, and why each input is there
116
+ *
117
+ * The shape follows `agentSourceHash` (`packages/build-agent/src/agent-hash.ts`)
118
+ * exactly: collect, label, sort by label with a codepoint comparison, and
119
+ * digest each input as `label` NUL `bytes` NUL. The framing matters - without
120
+ * the labels a file rename would leave the hash unchanged, and without the NUL
121
+ * separators two adjacent inputs could be split differently and collide.
122
+ *
123
+ * The inputs are a deliberate superset of what rolldown actually tree-shakes
124
+ * into the bundle: an unrelated change to this package or to core can force a
125
+ * (harmless) redeploy, which is the right trade against ever shipping a stale
126
+ * transform. `core/src` is in because the bundle genuinely inlines it - the
127
+ * entry constructs core's `SigningClient` and `SecretsManagerClient` - and the
128
+ * lockfile, `tsconfig.json` and the rolldown config are in because each of them
129
+ * changes the emitted bundle without changing a single line of this package's
130
+ * own source. The rolldown config lives at `src/transform/rolldown.config.ts`,
131
+ * so the `analytics/src` collection below already carries it; it is not listed
132
+ * a second time, because hashing the same bytes under two labels adds nothing.
133
+ * `transform-hash.test.ts` proves each of those inputs is live by changing one
134
+ * byte of it and asserting the hash moves.
135
+ *
136
+ * Test files are excluded (the `.test.ts` filter, as in `agentSourceHash`):
137
+ * they are never bundled, so a test-only edit must not redeploy the function.
138
+ *
139
+ * ## No direct filesystem call here
140
+ *
141
+ * Reading crosses the {@link FileSystem} port rather than Node's `fs` module,
142
+ * so this stays a domain module under DEVELOPMENT.md §Hexagonal architecture
143
+ * and no `packages/analytics/src/` path has to join the `no-restricted-imports`
144
+ * override list in `.oxlintrc.json`. The real adapter is constructed in
145
+ * `transform/write-manifest.ts`, the build-time edge that runs this.
146
+ */
147
+ /**
148
+ * Hex characters kept from the SHA-256 digest. Twelve, as `agentSourceHash`
149
+ * slices to: enough that a collision between two revisions of one small source
150
+ * tree is not a practical concern, short enough to read in a resource name.
151
+ */
152
+ const HASH_LENGTH = 12;
153
+ /** A well-formed {@link transformSourceHash} result. */
154
+ const HASH_PATTERN = new RegExp(`^[0-9a-f]{${HASH_LENGTH}}$`);
155
+ /**
156
+ * Where the build puts the Lambda artifacts, relative to the package root.
157
+ * Its own directory, not `dist/transform/` (which `tsc` fills with the
158
+ * unbundled modules), so the bundle can never be overwritten by the compiler.
159
+ *
160
+ * It holds two files, and they are not both deployment artifacts:
161
+ * {@link TRANSFORM_BUNDLE_FILE} is the zip's single entry, and
162
+ * {@link TRANSFORM_MANIFEST_FILE} is read *beside* the zip at deploy time for
163
+ * the hash and the key it derives. Task 50 zips the bundle file alone - see
164
+ * {@link TRANSFORM_BUNDLE_FILE} for why a one-file zip is the shape the
165
+ * `Handler` string assumes - and never the directory wholesale.
166
+ */
167
+ const TRANSFORM_BUNDLE_DIR = "dist/transform-bundle";
168
+ /**
169
+ * The bundle's file name inside {@link TRANSFORM_BUNDLE_DIR} - and, unchanged,
170
+ * inside the zip task 50 uploads.
171
+ *
172
+ * `.mjs`, not `.js`: the bundle is ESM, and the Lambda Node runtime reads a
173
+ * `.js` file in the deployment package as CommonJS unless the zip also carries
174
+ * a `package.json` declaring `"type": "module"`. A one-file zip with a `.mjs`
175
+ * extension needs no such companion, so there is no second file to forget - and
176
+ * this file, alone, is what that zip contains.
177
+ *
178
+ * That the emitted bundle really is ESM exporting this module's binding is
179
+ * asserted by `transform/write-manifest.ts` on every build, because no test
180
+ * sees the emitted file.
181
+ */
182
+ const TRANSFORM_BUNDLE_FILE = "index.mjs";
183
+ /**
184
+ * The build-time manifest carrying the hash and the key it derives, written
185
+ * beside the bundle in {@link TRANSFORM_BUNDLE_DIR} and read beside the zip -
186
+ * never packed inside it.
187
+ */
188
+ const TRANSFORM_MANIFEST_FILE = "transform-manifest.json";
189
+ /**
190
+ * The `Handler` string the function is configured with (task 50), spelled here
191
+ * because it is derived from this module's artifact names and nothing else:
192
+ * the bundle's base name, then the binding `transform/entry.ts` exports.
193
+ *
194
+ * It is here rather than in `nodes.ts` because getting it wrong fails at
195
+ * *invoke* time with an AWS-side error and no build error - every record would
196
+ * land in the Firehose error prefix with nothing in this repo reporting it, and
197
+ * the only symptom is an empty dashboard. Two checks pin this constant against
198
+ * a real export rather than a comment: `transform/entry.test.ts` against the
199
+ * entry *module*'s, and `transform/write-manifest.ts` against the emitted
200
+ * *bundle*'s, on every build. A rename or a bundler-config change reddens one
201
+ * of them instead of silently emptying the warehouse.
202
+ */
203
+ const TRANSFORM_LAMBDA_HANDLER = "index.handler";
204
+ /**
205
+ * Every non-test file under `root`, labelled by its path under `prefix`.
206
+ *
207
+ * `listFiles` is contracted to return sorted, `root`-relative paths, so the
208
+ * collection order does not depend on the host filesystem's readdir order;
209
+ * separators are normalised to `/` so a label is the same on every platform.
210
+ */
211
+ async function collectSource(fs, root, prefix) {
212
+ return (await fs.listFiles(root)).filter((relativePath) => !relativePath.endsWith(".test.ts")).map((relativePath) => ({
213
+ label: `${prefix}/${relativePath.split(sep).join("/")}`,
214
+ path: join(root, relativePath)
215
+ }));
216
+ }
217
+ /**
218
+ * A hash of the transform's *source*, computed at bundle time and stamped into
219
+ * `dist/transform-bundle/transform-manifest.json` (see
220
+ * `transform/write-manifest.ts`) so the plugin reads it at runtime without any
221
+ * access to the source tree.
222
+ *
223
+ * `dir` is the analytics package root; core and the workspace root are located
224
+ * from it the way `agentSourceHash` locates its own siblings. Only labels,
225
+ * never absolute paths, reach the digest, so the same tree checked out at two
226
+ * different paths hashes identically - a property `transform-hash.test.ts`
227
+ * asserts, because a hash that moves with the checkout directory would key the
228
+ * same code under a different zip on every machine.
229
+ */
230
+ async function transformSourceHash(dir, fs) {
231
+ const coreDir = join(dir, "..", "core");
232
+ const rootDir = join(dir, "..", "..");
233
+ const inputs = [
234
+ ...await collectSource(fs, join(dir, "src"), "analytics/src"),
235
+ ...await collectSource(fs, join(coreDir, "src"), "core/src"),
236
+ {
237
+ label: "analytics/package.json",
238
+ path: join(dir, "package.json")
239
+ },
240
+ {
241
+ label: "analytics/tsconfig.json",
242
+ path: join(dir, "tsconfig.json")
243
+ },
244
+ {
245
+ label: "core/package.json",
246
+ path: join(coreDir, "package.json")
247
+ },
248
+ {
249
+ label: "workspace/tsconfig.base.json",
250
+ path: join(rootDir, "tsconfig.base.json")
251
+ },
252
+ {
253
+ label: "workspace/pnpm-lock.yaml",
254
+ path: join(rootDir, "pnpm-lock.yaml")
255
+ }
256
+ ].sort((a, b) => a.label < b.label ? -1 : 1);
257
+ const digest = createHash("sha256");
258
+ for (const { label, path } of inputs) {
259
+ digest.update(label);
260
+ digest.update("\0");
261
+ digest.update(await fs.readBytes(path));
262
+ digest.update("\0");
263
+ }
264
+ return digest.digest("hex").slice(0, HASH_LENGTH);
265
+ }
266
+ /**
267
+ * The key the bundled transform's zip is stored and compared under, derived
268
+ * from {@link transformSourceHash} and spelled in this one place. Task 50's
269
+ * `analytics-transform-function` records it and skips the code update when it
270
+ * has not moved, so identical source provably maps to an identical key.
271
+ *
272
+ * A malformed hash raises rather than producing a key: `transform-undefined.zip`
273
+ * is a key that compares equal to itself forever, which would pin the deployed
274
+ * function at whatever code first shipped and never update it again.
275
+ */
276
+ function transformZipKey(hash) {
277
+ if (!HASH_PATTERN.test(hash)) throw new Error(`the analytics transform's source hash must be ${HASH_LENGTH} lowercase hex characters, not "${hash}" - rebuild the package so ${TRANSFORM_MANIFEST_FILE} is regenerated`);
278
+ return `analytics/transform/transform-${hash}.zip`;
279
+ }
280
+ //#endregion
281
+ //#region src/transform/write-manifest.ts
282
+ /**
283
+ * Build step: check the emitted bundle, then stamp
284
+ * `dist/transform-bundle/transform-manifest.json` with the reproducible source
285
+ * hash and the zip key derived from it.
286
+ *
287
+ * Runs after the rolldown bundle with the analytics package root as its cwd
288
+ * (see the package's `build` script), so the shipped artifact set - the bundle
289
+ * and this manifest - is self-describing and the plugin's function node reads
290
+ * the hash at deploy time without any access to the source trees. It is the
291
+ * `packages/build-agent/src/write-manifest.ts` precedent, moved onto the
292
+ * FileSystem port.
293
+ *
294
+ * This is the build-time edge component of the pair: it constructs the real
295
+ * disk adapter - core's `createNodeFileSystem` - which is why
296
+ * `transform-hash.ts` never has to. That keeps every `packages/analytics/src/`
297
+ * module free of a direct filesystem import and out of the `.oxlintrc.json`
298
+ * `no-restricted-imports` override list.
299
+ *
300
+ * ## Why the emitted bundle is checked here
301
+ *
302
+ * `entry.test.ts` pins the *source* module's exports against
303
+ * {@link TRANSFORM_LAMBDA_HANDLER}, and that is all it can do: under vitest
304
+ * `import('./entry.js')` resolves to `entry.ts`, never to the file rolldown
305
+ * emits. So everything between the source and the artifact - the output
306
+ * `format`, `codeSplitting`, the `input` rolldown is pointed at - is asserted
307
+ * by no test at all. Changing `format: 'esm'` to `'cjs'` in
308
+ * `rolldown.config.ts` is a one-token edit that leaves build, typecheck, test,
309
+ * lint, oxfmt and knip green while emitting a bundle Lambda cannot resolve
310
+ * `index.handler` from at all: every record would go to the Firehose error
311
+ * prefix, and the only symptom would be an empty dashboard - the same failure
312
+ * `entry.ts` and `transform-hash.ts` are written against, reached by a
313
+ * different route.
314
+ *
315
+ * The check therefore loads the emitted `.mjs` the way Lambda's runtime will
316
+ * and asserts the configured `Handler` resolves. It lives in this build step
317
+ * rather than in a `dist`-gated case in `entry.test.ts` for two reasons:
318
+ *
319
+ * - **It cannot be skipped.** This runs on every `pnpm build`; a test that
320
+ * needs `dist/` to exist runs only when someone happens to have built, which
321
+ * is a weaker guarantee than a build step in the chain.
322
+ * - **It runs before the stamp.** A bundle that fails the check never acquires
323
+ * a hash or a zip key, so task 50 has nothing to deploy rather than a fresh
324
+ * key over a function that cannot start.
325
+ */
326
+ /** Lambda reads `Handler` as `<module base name>.<exported binding>`. */
327
+ const [, handlerBinding] = TRANSFORM_LAMBDA_HANDLER.split(".");
328
+ /**
329
+ * A stand-in secret *name*, never a secret. `entry.ts` binds its handler at
330
+ * module load and the factory resolves the secret's name then, so importing
331
+ * the bundle at all requires {@link SALT_SECRET_NAME_ENV} to be set - that
332
+ * reject-at-init is deliberate, and `entry.test.ts` sets a placeholder for the
333
+ * same reason. Nothing is invoked and no secret is read: the check ends at the
334
+ * shape of the export.
335
+ */
336
+ const EXPORT_CHECK_SECRET_NAME = "build-time-export-check/not-a-real-secret";
337
+ /**
338
+ * Assert the bundle rolldown just emitted exports the binding the function's
339
+ * configured `Handler` names, as the async function Lambda awaits. Throws -
340
+ * failing `pnpm build` - if it does not.
341
+ */
342
+ async function assertBundleExportsHandler() {
343
+ if (!process.env["ANALYTICS_SALT_SECRET_NAME"]?.trim()) process.env[SALT_SECRET_NAME_ENV] = EXPORT_CHECK_SECRET_NAME;
344
+ const bundlePath = resolve(TRANSFORM_BUNDLE_DIR, TRANSFORM_BUNDLE_FILE);
345
+ let bundle;
346
+ try {
347
+ bundle = await import(pathToFileURL(bundlePath).href);
348
+ } catch (cause) {
349
+ throw new Error(`${TRANSFORM_BUNDLE_DIR}/${TRANSFORM_BUNDLE_FILE} could not be loaded as an ES module, so Lambda would fail to initialise it and every record would land in the Firehose error prefix - the cause below says why, and a rolldown output \`format\` other than 'esm' is the usual one`, { cause });
350
+ }
351
+ const bound = bundle[handlerBinding];
352
+ if (typeof bound !== "function") throw new Error(`${TRANSFORM_BUNDLE_DIR}/${TRANSFORM_BUNDLE_FILE} exports [${Object.keys(bundle).join(", ")}], so the configured Handler "${TRANSFORM_LAMBDA_HANDLER}" resolves to nothing at invoke time and every record would land in the Firehose error prefix - check the rolldown \`input\` and the binding \`transform/entry.ts\` exports`);
353
+ if (bound.constructor.name !== "AsyncFunction") throw new Error(`${TRANSFORM_BUNDLE_DIR}/${TRANSFORM_BUNDLE_FILE} exports "${handlerBinding}" as an ordinary ${bound.constructor.name}, not the async function Lambda awaits - \`transform/entry.ts\` must export the handler \`createTransformHandler\` returns, not the factory itself`);
354
+ }
355
+ await assertBundleExportsHandler();
356
+ const fs = createNodeFileSystem();
357
+ const hash = await transformSourceHash(process.cwd(), fs);
358
+ const manifest = {
359
+ hash,
360
+ key: transformZipKey(hash)
361
+ };
362
+ await fs.writeText(`${TRANSFORM_BUNDLE_DIR}/${TRANSFORM_MANIFEST_FILE}`, `${JSON.stringify(manifest, null, 2)}\n`);
363
+ console.log(`${TRANSFORM_MANIFEST_FILE}: ${hash}`);
364
+ //#endregion
365
+ export {};
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "blogwright-analytics",
3
+ "blogwright": {
4
+ "plugin": "analytics"
5
+ },
6
+ "version": "0.3.3",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "files": [
10
+ "dist",
11
+ "dist/app"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "rolldown -c src/transform/rolldown.config.ts && node dist/write-transform-manifest.mjs && tsc -p tsconfig.json && cd app && vite build",
21
+ "typecheck": "tsc -p tsconfig.typecheck.json && cd app && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings",
22
+ "lint": "oxlint src app",
23
+ "test": "vitest run"
24
+ },
25
+ "dependencies": {
26
+ "@duckdb/node-api": "1.5.5-r.4",
27
+ "blogwright-core": "workspace:*",
28
+ "fflate": "^0.8.3"
29
+ },
30
+ "devDependencies": {
31
+ "@sveltejs/adapter-static": "^3.0.10",
32
+ "@sveltejs/kit": "^2.70.3",
33
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
34
+ "@types/node": "^26.1.0",
35
+ "layerchart": "2.0.2",
36
+ "oxlint": "^1.72.0",
37
+ "rolldown": "^1.1.4",
38
+ "svelte": "^5.57.0",
39
+ "svelte-check": "^4.7.6",
40
+ "typescript": "^6.0.3",
41
+ "vite": "^8.2.2",
42
+ "vitest": "^4.1.9"
43
+ },
44
+ "description": "Traffic analytics for blogwright: CloudFront access logs routed through Firehose into an Iceberg table, with a local dashboard. Installed on demand with `blogwright plugin add analytics`, never shipped with the CLI.",
45
+ "keywords": [
46
+ "analytics",
47
+ "cloudfront",
48
+ "firehose",
49
+ "iceberg"
50
+ ],
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/antstanley/blogwright.git"
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ }
59
+ }