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.
- package/README.md +162 -0
- package/dist/adapters/duckdb-ingest.d.ts +76 -0
- package/dist/adapters/duckdb-ingest.js +173 -0
- package/dist/adapters/duckdb-query.d.ts +56 -0
- package/dist/adapters/duckdb-query.js +80 -0
- package/dist/adapters/duckdb-session.d.ts +168 -0
- package/dist/adapters/duckdb-session.js +330 -0
- package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
- package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
- package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
- package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
- package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
- package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
- package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
- package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
- package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
- package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
- package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
- package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
- package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
- package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
- package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
- package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
- package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
- package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
- package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
- package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
- package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
- package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
- package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
- package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
- package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
- package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
- package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
- package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
- package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
- package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
- package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
- package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
- package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
- package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
- package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
- package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
- package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
- package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
- package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
- package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
- package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
- package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
- package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
- package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
- package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
- package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
- package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
- package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
- package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
- package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
- package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
- package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
- package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
- package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
- package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
- package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
- package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
- package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
- package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
- package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
- package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
- package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
- package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
- package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
- package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
- package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
- package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
- package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
- package/dist/app/_app/version.json +1 -0
- package/dist/app/index.html +82 -0
- package/dist/aws/clients.d.ts +70 -0
- package/dist/aws/clients.js +52 -0
- package/dist/aws/errors.d.ts +41 -0
- package/dist/aws/errors.js +70 -0
- package/dist/aws/firehose.d.ts +228 -0
- package/dist/aws/firehose.js +347 -0
- package/dist/aws/glue.d.ts +103 -0
- package/dist/aws/glue.js +225 -0
- package/dist/aws/lambda.d.ts +132 -0
- package/dist/aws/lambda.js +339 -0
- package/dist/aws/s3tables.d.ts +120 -0
- package/dist/aws/s3tables.js +281 -0
- package/dist/backfill.d.ts +100 -0
- package/dist/backfill.js +294 -0
- package/dist/commands.d.ts +124 -0
- package/dist/commands.js +336 -0
- package/dist/config.d.ts +162 -0
- package/dist/config.js +317 -0
- package/dist/fixture-ingest.d.ts +49 -0
- package/dist/fixture-ingest.js +43 -0
- package/dist/fixture-query.d.ts +39 -0
- package/dist/fixture-query.js +70 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/nodes.d.ts +404 -0
- package/dist/nodes.js +2708 -0
- package/dist/paths.d.ts +45 -0
- package/dist/paths.js +47 -0
- package/dist/plugin.d.ts +102 -0
- package/dist/plugin.js +248 -0
- package/dist/ports.d.ts +113 -0
- package/dist/ports.js +35 -0
- package/dist/queries.d.ts +301 -0
- package/dist/queries.js +414 -0
- package/dist/schema.d.ts +240 -0
- package/dist/schema.js +154 -0
- package/dist/server.d.ts +150 -0
- package/dist/server.js +499 -0
- package/dist/transform/bots.d.ts +47 -0
- package/dist/transform/bots.js +73 -0
- package/dist/transform/handler.d.ts +135 -0
- package/dist/transform/handler.js +177 -0
- package/dist/transform/map-record.d.ts +110 -0
- package/dist/transform/map-record.js +275 -0
- package/dist/transform/visitor-key.d.ts +83 -0
- package/dist/transform/visitor-key.js +120 -0
- package/dist/transform-bundle/index.mjs +21456 -0
- package/dist/transform-bundle/transform-manifest.json +4 -0
- package/dist/transform-hash.d.ts +135 -0
- package/dist/transform-hash.js +186 -0
- package/dist/write-transform-manifest.mjs +365 -0
- 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
|
+
}
|