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,135 @@
1
+ /**
2
+ * The Firehose transform Lambda's envelope: the batch handler that wraps
3
+ * `mapRecord`. This is step 5 of
4
+ * [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md) -
5
+ * "drops records the schema cannot accept, emitting them to the Firehose error
6
+ * prefix rather than failing the batch" - plus the cold-start secret read step
7
+ * 3 calls for.
8
+ *
9
+ * It owns four things and nothing else: the envelope types, the base64 and
10
+ * JSON boundary, the cold-start read of the long-lived salt secret, and the
11
+ * translation of one `MapRecordResult` into one Firehose response entry. Every
12
+ * decision about *what a row is* - which field fills which column, which
13
+ * values are unusable, how `visitor_key` is derived and under which day's salt
14
+ * - belongs to `map-record.ts` and is forwarded here, never re-decided.
15
+ *
16
+ * ## The two outcomes, and why they are different
17
+ *
18
+ * Firehose's per-record vocabulary distinguishes them and so does this module:
19
+ *
20
+ * - **`Ok`** carries the mapped row, re-encoded. Firehose writes it.
21
+ * - **`ProcessingFailed`** carries no data. Firehose writes the *original*
22
+ * record to the error prefix and delivers the rest of the batch. That is the
23
+ * visible data-quality signal a record the schema cannot accept should
24
+ * produce.
25
+ *
26
+ * Firehose's third result, `Dropped`, is deliberately unused: it discards the
27
+ * record silently, which is the failure mode this whole pipeline is written
28
+ * against. A record that cannot be mapped must end up somewhere an operator
29
+ * can read it.
30
+ *
31
+ * `recordId` is echoed verbatim onto every entry, `Ok` and `ProcessingFailed`
32
+ * alike. Firehose discards a response whose ids do not match the request's -
33
+ * not the mismatched entry, the response - so an id dropped from a failed
34
+ * entry loses the whole batch.
35
+ *
36
+ * ## Why there is no try/catch around the mapping
37
+ *
38
+ * `dailySalt` and `visitorKey` throw on an empty secret, day, IP or salt
39
+ * (`visitor-key.ts`), because an unsalted digest that looks protected is worse
40
+ * than a failed batch: the table stores `user_agent` in the clear beside
41
+ * `visitor_key`, and an unsalted SHA-256 of an IPv4 address is a lookup table
42
+ * over a 2^32 space. A blanket catch around `mapRecord` would convert those
43
+ * throws into `ProcessingFailed` for every record - the whole batch to the
44
+ * error prefix, no error anywhere, an empty dashboard, and nothing to say why.
45
+ * So the only `try` in this module is the one around `JSON.parse`, which is
46
+ * this module's own boundary. A throw from the mapping propagates, the
47
+ * invocation fails, and Firehose retries the batch and raises its own error
48
+ * metric. Loudly is the point.
49
+ *
50
+ * The same reasoning governs the secret: a failed or empty read fails the
51
+ * batch. There is no unsalted or date-only fallback, because a fallback would
52
+ * write unprotected data that looks protected, and no reprocessing could
53
+ * repair rows already written under it.
54
+ *
55
+ * ## No AWS SDK, no network, no clock
56
+ *
57
+ * The envelope types below are repo-owned declarations, not `@types/aws-lambda`
58
+ * - the package takes no dependency for a shape this small and this stable.
59
+ * The one thing this module cannot do without is the secret, and it reaches it
60
+ * through {@link SaltSecretStore}, a structural slice of core's own client
61
+ * (the `packages/pds/src/secret.ts` precedent). That import is type-only, so it
62
+ * erases at compile: this module carries no client, no signer and no transport,
63
+ * and its tests stub the store with a plain object rather than mocking a module
64
+ * or reaching a cloud.
65
+ *
66
+ * Which also means this module is not a composition root. Binding a real
67
+ * `SecretsManagerClient` - built over the us-east-1 signer, since the function
68
+ * and its secret both live there - is the bundle entry's job, and it is the
69
+ * caller that hands the constructed handler to Lambda.
70
+ */
71
+ import type { SecretsManagerClient } from 'blogwright-core';
72
+ /**
73
+ * One record as Firehose sends it. The event carries more - `invocationId`,
74
+ * `deliveryStreamArn`, `region`, and per-record source metadata - and this
75
+ * handler reads none of it, so none of it is declared: a field spelled here
76
+ * and never read is a field a later reader would believe is load-bearing.
77
+ */
78
+ interface FirehoseTransformRecord {
79
+ /** Firehose's id for this record. Echoed onto the response entry unchanged. */
80
+ readonly recordId: string;
81
+ /** The record's payload, base64-encoded. */
82
+ readonly data: string;
83
+ }
84
+ /** The transform invocation's payload: one buffer of records to translate. */
85
+ export interface FirehoseTransformRequest {
86
+ readonly records: readonly FirehoseTransformRecord[];
87
+ }
88
+ /**
89
+ * What Firehose does with one record. `Dropped` exists in the API and is not
90
+ * offered here - see the module comment.
91
+ */
92
+ type FirehoseRecordResult = 'Ok' | 'ProcessingFailed';
93
+ /** One record's outcome, in Firehose's vocabulary. */
94
+ interface FirehoseTransformedRecord {
95
+ /** The request entry's id, unchanged. */
96
+ readonly recordId: string;
97
+ readonly result: FirehoseRecordResult;
98
+ /** The transformed payload, base64-encoded. Present only for `Ok`. */
99
+ readonly data?: string | undefined;
100
+ }
101
+ /** The transform's response: one entry per request record, in request order. */
102
+ export interface FirehoseTransformResponse {
103
+ readonly records: readonly FirehoseTransformedRecord[];
104
+ }
105
+ /**
106
+ * The read this handler needs, as a structural slice of core's client rather
107
+ * than a hand-rolled function type, so the seam is checked against the real
108
+ * `SecretsManagerClient` and a test satisfies it with a plain object.
109
+ */
110
+ export type SaltSecretStore = Pick<SecretsManagerClient, 'getSecretValue'>;
111
+ /**
112
+ * The environment variable naming the Secrets Manager secret behind
113
+ * `visitor_key`, set on the function by the `analytics-transform-function`
114
+ * node (task 50). The *name* travels in the environment; the value never does,
115
+ * so it cannot be read off the function's configuration.
116
+ */
117
+ export declare const SALT_SECRET_NAME_ENV = "ANALYTICS_SALT_SECRET_NAME";
118
+ /** The environment as the runtime hands it over. */
119
+ type Environment = Readonly<Record<string, string | undefined>>;
120
+ /**
121
+ * Build the Lambda entry point over a Secrets Manager read.
122
+ *
123
+ * The secret's name is resolved now, so a function deployed without
124
+ * {@link SALT_SECRET_NAME_ENV} fails at initialisation rather than once per
125
+ * batch. Its *value* is read on the first invocation and cached for the life
126
+ * of the execution environment: a 60-second Firehose buffer is roughly 43,000
127
+ * invocations a month, so reading per invocation would spend more than half
128
+ * the price of the secret itself again on `GetSecretValue` calls, for nothing.
129
+ *
130
+ * A failed read is not cached. Caching the failure would let one throttled
131
+ * call disable an execution environment for its whole life, failing every
132
+ * batch it ever sees; leaving it uncached means the next invocation retries.
133
+ */
134
+ export declare function createTransformHandler(secrets: SaltSecretStore, env?: Environment): (request: FirehoseTransformRequest) => Promise<FirehoseTransformResponse>;
135
+ export {};
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The Firehose transform Lambda's envelope: the batch handler that wraps
3
+ * `mapRecord`. This is step 5 of
4
+ * [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md) -
5
+ * "drops records the schema cannot accept, emitting them to the Firehose error
6
+ * prefix rather than failing the batch" - plus the cold-start secret read step
7
+ * 3 calls for.
8
+ *
9
+ * It owns four things and nothing else: the envelope types, the base64 and
10
+ * JSON boundary, the cold-start read of the long-lived salt secret, and the
11
+ * translation of one `MapRecordResult` into one Firehose response entry. Every
12
+ * decision about *what a row is* - which field fills which column, which
13
+ * values are unusable, how `visitor_key` is derived and under which day's salt
14
+ * - belongs to `map-record.ts` and is forwarded here, never re-decided.
15
+ *
16
+ * ## The two outcomes, and why they are different
17
+ *
18
+ * Firehose's per-record vocabulary distinguishes them and so does this module:
19
+ *
20
+ * - **`Ok`** carries the mapped row, re-encoded. Firehose writes it.
21
+ * - **`ProcessingFailed`** carries no data. Firehose writes the *original*
22
+ * record to the error prefix and delivers the rest of the batch. That is the
23
+ * visible data-quality signal a record the schema cannot accept should
24
+ * produce.
25
+ *
26
+ * Firehose's third result, `Dropped`, is deliberately unused: it discards the
27
+ * record silently, which is the failure mode this whole pipeline is written
28
+ * against. A record that cannot be mapped must end up somewhere an operator
29
+ * can read it.
30
+ *
31
+ * `recordId` is echoed verbatim onto every entry, `Ok` and `ProcessingFailed`
32
+ * alike. Firehose discards a response whose ids do not match the request's -
33
+ * not the mismatched entry, the response - so an id dropped from a failed
34
+ * entry loses the whole batch.
35
+ *
36
+ * ## Why there is no try/catch around the mapping
37
+ *
38
+ * `dailySalt` and `visitorKey` throw on an empty secret, day, IP or salt
39
+ * (`visitor-key.ts`), because an unsalted digest that looks protected is worse
40
+ * than a failed batch: the table stores `user_agent` in the clear beside
41
+ * `visitor_key`, and an unsalted SHA-256 of an IPv4 address is a lookup table
42
+ * over a 2^32 space. A blanket catch around `mapRecord` would convert those
43
+ * throws into `ProcessingFailed` for every record - the whole batch to the
44
+ * error prefix, no error anywhere, an empty dashboard, and nothing to say why.
45
+ * So the only `try` in this module is the one around `JSON.parse`, which is
46
+ * this module's own boundary. A throw from the mapping propagates, the
47
+ * invocation fails, and Firehose retries the batch and raises its own error
48
+ * metric. Loudly is the point.
49
+ *
50
+ * The same reasoning governs the secret: a failed or empty read fails the
51
+ * batch. There is no unsalted or date-only fallback, because a fallback would
52
+ * write unprotected data that looks protected, and no reprocessing could
53
+ * repair rows already written under it.
54
+ *
55
+ * ## No AWS SDK, no network, no clock
56
+ *
57
+ * The envelope types below are repo-owned declarations, not `@types/aws-lambda`
58
+ * - the package takes no dependency for a shape this small and this stable.
59
+ * The one thing this module cannot do without is the secret, and it reaches it
60
+ * through {@link SaltSecretStore}, a structural slice of core's own client
61
+ * (the `packages/pds/src/secret.ts` precedent). That import is type-only, so it
62
+ * erases at compile: this module carries no client, no signer and no transport,
63
+ * and its tests stub the store with a plain object rather than mocking a module
64
+ * or reaching a cloud.
65
+ *
66
+ * Which also means this module is not a composition root. Binding a real
67
+ * `SecretsManagerClient` - built over the us-east-1 signer, since the function
68
+ * and its secret both live there - is the bundle entry's job, and it is the
69
+ * caller that hands the constructed handler to Lambda.
70
+ */
71
+ import { mapRecord } from './map-record.js';
72
+ /** Firehose writes the record. */
73
+ const OK = 'Ok';
74
+ /** Firehose writes the *original* record to the error prefix and carries on. */
75
+ const PROCESSING_FAILED = 'ProcessingFailed';
76
+ /**
77
+ * The environment variable naming the Secrets Manager secret behind
78
+ * `visitor_key`, set on the function by the `analytics-transform-function`
79
+ * node (task 50). The *name* travels in the environment; the value never does,
80
+ * so it cannot be read off the function's configuration.
81
+ */
82
+ export const SALT_SECRET_NAME_ENV = 'ANALYTICS_SALT_SECRET_NAME';
83
+ /** The secret's name, or a failure naming the variable that should carry it. */
84
+ function requireSaltSecretName(env) {
85
+ const name = env[SALT_SECRET_NAME_ENV]?.trim();
86
+ if (name === undefined || name === '') {
87
+ throw new Error(`the analytics transform needs the salt secret's name in ${SALT_SECRET_NAME_ENV}: without it every visitor_key would be unsalted, so the function refuses to start`);
88
+ }
89
+ return name;
90
+ }
91
+ /**
92
+ * The stored secret, or a failure. Never the empty string and never a
93
+ * substitute: `dailySalt` would throw on a blank secret anyway, and this says
94
+ * which secret is blank and how to fix it.
95
+ *
96
+ * The value is returned untrimmed. It is opaque key material, and trimming it
97
+ * would derive a different salt from the same secret - orphaning every
98
+ * `visitor_key` already written.
99
+ */
100
+ async function loadSaltSecret(secrets, secretName) {
101
+ const secret = await secrets.getSecretValue(secretName);
102
+ if (secret === undefined || secret.trim() === '') {
103
+ throw new Error(`the analytics salt secret "${secretName}" holds no value: an unsalted visitor_key would identify the visitor it exists to hide, so this batch fails instead - run \`blogwright analytics bootstrap\` to create the secret`);
104
+ }
105
+ return secret;
106
+ }
107
+ /**
108
+ * The record's payload as an object, or `undefined` when it is not one.
109
+ *
110
+ * `Buffer.from(data, 'base64')` never throws - it ignores what it cannot
111
+ * decode - so a corrupt payload surfaces here as JSON that will not parse.
112
+ * `mapRecord` documents that it trusts its record to be an object and that
113
+ * parsing is this module's boundary, which is why a non-object payload (a
114
+ * bare number, `null`, an array) is rejected here rather than there.
115
+ *
116
+ * This `try` covers `JSON.parse` and nothing else. Widening it to cover the
117
+ * mapping would turn `visitor-key.ts`'s deliberate throws into silent drops.
118
+ */
119
+ function decodePayload(data) {
120
+ let parsed;
121
+ try {
122
+ parsed = JSON.parse(Buffer.from(data, 'base64').toString('utf8'));
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
128
+ return undefined;
129
+ return parsed;
130
+ }
131
+ /**
132
+ * One record in, one response entry out. The record is mapped with the whole
133
+ * batch's secret but under its *own* day's salt, which `mapRecord` derives
134
+ * from the record's own `timestamp(ms)` - a buffer that straddles midnight
135
+ * carries records of two days, and one salt chosen per invocation would be the
136
+ * wrong day's for every record on the far side of it.
137
+ */
138
+ function transformRecord(record, saltSecret) {
139
+ const payload = decodePayload(record.data);
140
+ if (payload === undefined)
141
+ return { recordId: record.recordId, result: PROCESSING_FAILED };
142
+ const mapped = mapRecord(payload, saltSecret);
143
+ if (!mapped.mapped)
144
+ return { recordId: record.recordId, result: PROCESSING_FAILED };
145
+ return {
146
+ recordId: record.recordId,
147
+ result: OK,
148
+ data: Buffer.from(JSON.stringify(mapped.row), 'utf8').toString('base64'),
149
+ };
150
+ }
151
+ /**
152
+ * Build the Lambda entry point over a Secrets Manager read.
153
+ *
154
+ * The secret's name is resolved now, so a function deployed without
155
+ * {@link SALT_SECRET_NAME_ENV} fails at initialisation rather than once per
156
+ * batch. Its *value* is read on the first invocation and cached for the life
157
+ * of the execution environment: a 60-second Firehose buffer is roughly 43,000
158
+ * invocations a month, so reading per invocation would spend more than half
159
+ * the price of the secret itself again on `GetSecretValue` calls, for nothing.
160
+ *
161
+ * A failed read is not cached. Caching the failure would let one throttled
162
+ * call disable an execution environment for its whole life, failing every
163
+ * batch it ever sees; leaving it uncached means the next invocation retries.
164
+ */
165
+ export function createTransformHandler(secrets, env = process.env) {
166
+ const secretName = requireSaltSecretName(env);
167
+ let cached;
168
+ async function readSaltSecret() {
169
+ if (cached === undefined)
170
+ cached = await loadSaltSecret(secrets, secretName);
171
+ return cached;
172
+ }
173
+ return async function transformFirehoseRecords(request) {
174
+ const saltSecret = await readSaltSecret();
175
+ return { records: request.records.map((record) => transformRecord(record, saltSecret)) };
176
+ };
177
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `mapRecord`: one CloudFront standard-logging (v2) record in, one `page_views`
3
+ * row out - or a droppable result naming the column that could not be filled
4
+ * and the CloudFront field behind it. This is all five steps of
5
+ * [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md):
6
+ * rename each selected field to its column, derive `event_time` and the `day`
7
+ * partition from `timestamp(ms)`, replace the viewer IP with `visitor_key`,
8
+ * set `is_bot` from the user agent, and drop what the schema cannot accept.
9
+ *
10
+ * The viewer IP is an *input* here and never a value. It reaches `visitorKey`
11
+ * and nothing else: it has no `FIELD_TO_COLUMN` entry to be renamed through,
12
+ * and `map-record.test.ts` searches every value of every produced row for it.
13
+ * That search is the standing check that no later field addition puts a raw
14
+ * address in the warehouse.
15
+ *
16
+ * The salt secret is a parameter for the same reason the day is read off the
17
+ * record: this function has no clock and reads no secret. The caller supplies
18
+ * the long-lived stored secret - the transform reads it once at cold start, a
19
+ * backfill reads the same one - and the day's salt is derived here rather than
20
+ * by the caller, because the day comes from the record's own timestamp. A
21
+ * batch can straddle midnight, so one salt chosen for a whole batch would be
22
+ * the wrong day's for the records on the other side of it.
23
+ *
24
+ * Why the drop path exists at all: Firehose matches incoming JSON keys to
25
+ * Iceberg column names **exactly**, and a record it cannot match goes to the
26
+ * S3 error bucket with no error anywhere an operator will see it - the symptom
27
+ * is an empty dashboard. So this module never emits a key that is not a column
28
+ * and never emits a value whose JavaScript type is not the one the column
29
+ * stores. Where it cannot honour that, it returns the droppable result and the
30
+ * envelope (task 42) reports `ProcessingFailed` for that one record, which
31
+ * routes it to the error prefix without failing the batch.
32
+ *
33
+ * Every column and field name comes from `schema.ts`: the row is built by
34
+ * iterating `FIELD_TO_COLUMN`, the required set and the numeric set are
35
+ * derived from `PAGE_VIEWS_COLUMNS`, and the only names spelled here are the
36
+ * four derived columns and the one column the two of them read back
37
+ * (`user_agent`), each checked against `PageViewColumnName` so a typo is a
38
+ * compile error rather than a column Firehose silently drops.
39
+ *
40
+ * Three input decisions the spec left open, settled here:
41
+ *
42
+ * - **`-` and the empty string mean absent.** CloudFront writes `-` for a
43
+ * field the request had nothing to say for (no referrer, no query string).
44
+ * Writing that through would fill `referrer` with a wall of `-`; treating it
45
+ * as absent leaves the column null, which is what it means. For a *required*
46
+ * column it is a drop, not a null.
47
+ * - **A number where a string column is expected is rendered, not rejected.**
48
+ * `asn` is a string column that a JSON encoder may well emit unquoted;
49
+ * `String(64512)` is the same value in the type the column stores. Anything
50
+ * that is not a string or a number - an object, an array, a boolean - is a
51
+ * drop, because there is no rendering of it that is not a guess.
52
+ * - **A numeric column that does not parse is a drop, never a coerced value.**
53
+ * `Number('abc')` is `NaN` and `Number('')` is `0`; writing either would put
54
+ * a wrong number in the table, which is worse than losing the record.
55
+ *
56
+ * Nullability governs the *absent* case only. A value that is present but
57
+ * unusable drops the record whether or not the column is nullable: leaving a
58
+ * nullable column empty asserts the request had nothing to say for it, which
59
+ * is a different - and false - fact about that request.
60
+ *
61
+ * Pure: no clock (`event_time` comes from the record's own `timestamp(ms)`, in
62
+ * UTC), no secret read, no vendor SDK, no `fetch`. The one `node:` builtin
63
+ * this directory uses is `node:crypto`, reached through `visitor-key.ts` and
64
+ * only for the digests - the same import `packages/core/src/aws/s3.ts` makes.
65
+ * The record arrives already parsed and is trusted to be an object - JSON
66
+ * parsing and its failures are the handler's boundary, not this function's.
67
+ */
68
+ import { type PageView, type PageViewColumnName } from '../schema.js';
69
+ /**
70
+ * One CloudFront access-log record as the delivery hands it over: the selected
71
+ * field names of `CLOUDFRONT_RECORD_FIELDS` against values of whatever type
72
+ * the JSON payload carried.
73
+ */
74
+ export type CloudFrontRecord = Readonly<Record<string, unknown>>;
75
+ /** A record that maps cleanly: the row is complete and safe to hand Firehose. */
76
+ interface MappedRecord {
77
+ readonly mapped: true;
78
+ readonly row: PageView;
79
+ }
80
+ /**
81
+ * A record the schema cannot accept. It carries no row at all - a partially
82
+ * populated one is exactly the silent corruption the drop path exists to
83
+ * prevent.
84
+ */
85
+ interface DroppedRecord {
86
+ readonly mapped: false;
87
+ /** The `page_views` column that could not be filled. */
88
+ readonly column: PageViewColumnName;
89
+ /** The CloudFront field that column reads. */
90
+ readonly field: string;
91
+ /** Names both, plus what was wrong with the value. */
92
+ readonly reason: string;
93
+ }
94
+ /** The outcome of mapping one record: a complete row, or a named drop. */
95
+ export type MapRecordResult = MappedRecord | DroppedRecord;
96
+ /**
97
+ * Turns one CloudFront access-log record into a `page_views` row, or reports
98
+ * why it cannot. Never returns a partially populated row: the first column it
99
+ * cannot fill ends the mapping.
100
+ *
101
+ * `saltSecret` is the long-lived stored secret behind `visitor_key`, not the
102
+ * day's salt - that is derived here from the record's own day. It is required
103
+ * rather than optional on purpose: a row mapped without it would carry no
104
+ * visitor at all, or worse an unsalted digest, and either would look like a
105
+ * normal row in the table. `dailySalt` throws on an empty secret for the same
106
+ * reason, so a caller whose secret read failed fails its batch instead of
107
+ * quietly writing unprotected data.
108
+ */
109
+ export declare function mapRecord(record: CloudFrontRecord, saltSecret: string): MapRecordResult;
110
+ export {};