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,70 @@
1
+ /**
2
+ * The two error-framing helpers every client in this directory shares.
3
+ *
4
+ * `s3tables.ts`, `firehose.ts`, `glue.ts` and `lambda.ts` each grew a
5
+ * byte-identical private copy of both, one per task, because extracting them
6
+ * would have meant editing a landed sibling no contract had opened. This module
7
+ * is where that extraction lands; the four now import from here.
8
+ *
9
+ * **Only the framing is shared.** The four already-exists predicates stay
10
+ * private to their own modules and must NOT be collapsed into one here - they
11
+ * key on genuinely different signals:
12
+ *
13
+ * - `s3tables.ts` needs a `statusCode === 409` limb, because S3 Tables returns
14
+ * its error code only in an `x-amzn-ErrorType` header that core's
15
+ * `parseError` never reads, so `code` is `"Http409"`;
16
+ * - `lambda.ts` needs a 409 limb for the same reason;
17
+ * - every **Firehose** exception is HTTP 400, so on that service `code` is the
18
+ * only usable signal and a status limb would match nothing;
19
+ * - **Glue** is AWS-JSON and narrows with core's `AwsError.isAlreadyExists`
20
+ * unmodified.
21
+ *
22
+ * A single status-keyed helper would therefore be silently wrong on two of the
23
+ * four. There is a second reason not to reach for one: on both Firehose and
24
+ * Lambda the already-exists code is *overloaded on the delete/update path* -
25
+ * Firehose answers `DeleteDeliveryStream` with `ResourceInUseException` meaning
26
+ * "still CREATING, cannot be deleted yet", and Lambda answers a delete or
27
+ * update with `ResourceConflictException` meaning "another operation is in
28
+ * progress". Reusing a create-path predicate on either would report a live
29
+ * resource as already torn down. Each module names its predicate for the create
30
+ * path (`isStreamAlreadyExists`, `isFunctionAlreadyExists`) for exactly that
31
+ * reason.
32
+ */
33
+ import { AwsError } from 'blogwright-core';
34
+ /**
35
+ * Strip the `AwsError` constructor's own `${service}: ${code} - … (HTTP
36
+ * ${statusCode})` framing back to the underlying AWS message, so a
37
+ * context-prefixed rethrow does not repeat it. Not exported: it was private to
38
+ * each of the four copies and `rethrowWithContext` is still its only caller, so
39
+ * exporting it would leave `pnpm knip` reporting an export nothing consumes.
40
+ */
41
+ function stripAwsFraming(err) {
42
+ const prefix = `${err.service}: ${err.code} - `;
43
+ const suffix = ` (HTTP ${err.statusCode})`;
44
+ let message = err.message;
45
+ if (message.startsWith(prefix))
46
+ message = message.slice(prefix.length);
47
+ if (message.endsWith(suffix))
48
+ message = message.slice(0, -suffix.length);
49
+ return message;
50
+ }
51
+ /**
52
+ * Rethrow a failure that was not the expected not-found/already-exists case as
53
+ * an `AwsError` naming the operation and the offending resource - a bucket ARN,
54
+ * a stream, a catalog, a function - preserving the original `code`,
55
+ * `statusCode` and `requestId` so `isNotFound` and each module's own
56
+ * already-exists predicate still narrow it downstream. A non-`AwsError` (e.g. a
57
+ * network-level failure) passes through unchanged.
58
+ */
59
+ export function rethrowWithContext(err, operation, resource) {
60
+ if (err instanceof AwsError) {
61
+ throw new AwsError({
62
+ service: err.service,
63
+ code: err.code,
64
+ statusCode: err.statusCode,
65
+ requestId: err.requestId,
66
+ message: `${operation} "${resource}": ${stripAwsFraming(err)}`,
67
+ });
68
+ }
69
+ throw err;
70
+ }
@@ -0,0 +1,228 @@
1
+ import { type ResourceTags, type SigningClient } from 'blogwright-core';
2
+ /**
3
+ * `page_views` is insert-only by design, so the stream is created append-only - the
4
+ * analytics change spec settles this ("*The stream is created `AppendOnly`.*"), and it
5
+ * also lets Firehose scale the stream's throughput limit automatically.
6
+ *
7
+ * A module constant rather than a field of {@link IcebergDestinationInput}: this client
8
+ * builds exactly one shape of destination, and both operations that send one -
9
+ * `CreateDeliveryStream` and `UpdateDestination` - send this same value, so there is no
10
+ * call site that could ever ask for anything else. Whether the flag is mutable after
11
+ * creation is unsettled between two AWS documentation pages, and the node that
12
+ * reconciles the stream (not this client) is where that question is answered - which is
13
+ * why the constant is **exported**: the node compares it against the flag
14
+ * {@link DeliveryStreamStatus.appendOnly} read back off the live stream, and a second
15
+ * copy of the desired value in `nodes.ts` would let the two drift apart in silence.
16
+ */
17
+ export declare const STREAM_APPEND_ONLY = true;
18
+ /**
19
+ * The Iceberg destination a delivery stream is created against, as a typed input
20
+ * rather than a raw `IcebergDestinationConfiguration` object: the caller names its
21
+ * resources in the plugin's own vocabulary and this module owns the translation to
22
+ * the wire, which is where every AWS spelling is spelled out exactly once.
23
+ */
24
+ export interface IcebergDestinationInput {
25
+ /**
26
+ * The Glue catalog ARN the S3 Tables bucket is federated into
27
+ * (`CatalogConfiguration.CatalogARN`). Firehose reads the Iceberg table through this
28
+ * catalog, never through S3 Tables directly.
29
+ *
30
+ * The field's prose names the bare `arn:aws:glue:<region>:<account-id>:catalog` form,
31
+ * but its pattern is `arn:.*:glue:.*:\d{12}:catalog(?:(/[a-z0-9_-]+){1,2})?` - up to
32
+ * two further segments - and an S3 Tables destination needs both of them:
33
+ * `…:catalog/s3tablescatalog/<table-bucket>`, the child catalog the federation creates
34
+ * per table bucket. The bare form names the account's own Data Catalog, which holds no
35
+ * S3 Tables table at all. The stream node derives the child form; this is recorded
36
+ * here so nobody "corrects" it back to the prose.
37
+ */
38
+ readonly catalogArn: string;
39
+ /**
40
+ * The delivery role Firehose assumes (`IcebergDestinationConfiguration.RoleARN`).
41
+ * The same role is used for the error bucket's `S3Configuration.RoleARN`: the
42
+ * analytics spec provisions one `analytics-firehose-role` granting Glue, S3 Tables,
43
+ * Lambda invoke and the error bucket together, so a second role would have nothing
44
+ * different to grant.
45
+ */
46
+ readonly roleArn: string;
47
+ /** The Iceberg namespace holding the table, sent as `DestinationDatabaseName`. */
48
+ readonly namespace: string;
49
+ /** The Iceberg table records are written to, sent as `DestinationTableName`. */
50
+ readonly tableName: string;
51
+ /** ARN of the S3 bucket failed records are written to, as `arn:aws:s3:::<bucket>`. */
52
+ readonly errorBucketArn: string;
53
+ /**
54
+ * Key prefix under `errorBucketArn` that failed records land at. Sent twice, because
55
+ * the service has two distinct error prefixes and they are not interchangeable: the
56
+ * stream-level `S3Configuration.ErrorOutputPrefix` covers failures that never reached
57
+ * a table (a rejected transform, a delivery error), while the table-level
58
+ * `DestinationTableConfiguration.S3ErrorOutputPrefix` covers records rejected by that
59
+ * one table's schema - the dominant failure mode here, since Firehose routes every
60
+ * record whose keys do not match the Iceberg columns to the error bucket. One prefix
61
+ * serves both because a single stream writes to a single table.
62
+ */
63
+ readonly errorOutputPrefix: string;
64
+ /** Buffer flush interval in **seconds** (`BufferingHints.IntervalInSeconds`). */
65
+ readonly bufferIntervalSeconds: number;
66
+ /** Buffer flush size in **MiB** (`BufferingHints.SizeInMBs`). */
67
+ readonly bufferSizeMb: number;
68
+ /**
69
+ * ARN of the Lambda that transforms every record before delivery. Mandatory rather
70
+ * than optional: CloudFront's field names are not the Iceberg column names, and
71
+ * Firehose sends every unmatched record to the error bucket, so a stream created
72
+ * without this processor would fail every record with nothing surfacing as an error.
73
+ */
74
+ readonly transformLambdaArn: string;
75
+ }
76
+ /**
77
+ * A delivery stream's lifecycle state in this repo's vocabulary rather than the
78
+ * service's `CREATING | CREATING_FAILED | DELETING | DELETING_FAILED | ACTIVE`
79
+ * screaming case, so `analytics status` reports health without re-reading the raw
80
+ * response. `unknown` covers a state the service adds later: reporting an unrecognised
81
+ * state as unknown is honest, where mapping it onto one of the five would not be.
82
+ */
83
+ export type DeliveryState = 'creating' | 'create-failed' | 'active' | 'deleting' | 'delete-failed' | 'unknown';
84
+ /**
85
+ * The narrow view of `DescribeDeliveryStream` that `analytics status` needs, plus the
86
+ * three fields {@link FirehoseClient.updateDestination} cannot be called without.
87
+ *
88
+ * The last three are optional and are set only when the response actually carried them.
89
+ * `VersionId` and `Destinations` are documented as required members of
90
+ * `DeliveryStreamDescription`, so on the service's own response model they are always
91
+ * there - but an absent one must reach the caller as absent rather than as `''` or
92
+ * `false`, because an empty `CurrentDeliveryStreamVersionId` fails the service's own
93
+ * `[0-9]+` pattern and a fabricated `appendOnly: false` would make the stream node
94
+ * replace a stream that needed nothing done to it.
95
+ */
96
+ export interface DeliveryStreamStatus {
97
+ readonly name: string;
98
+ readonly arn: string;
99
+ readonly state: DeliveryState;
100
+ /**
101
+ * The service's last failure detail, present only when one is reported - which the
102
+ * API documents for a create or delete that failed on a KMS error. Absent (the key
103
+ * is not set at all) on a healthy stream.
104
+ */
105
+ readonly failure?: string | undefined;
106
+ /**
107
+ * The stream's current configuration version, off `DeliveryStreamDescription.VersionId`.
108
+ * `UpdateDestination` refuses to run without it (it is how the service avoids
109
+ * conflicting merges) and calls the request key `CurrentDeliveryStreamVersionId`.
110
+ */
111
+ readonly versionId?: string | undefined;
112
+ /**
113
+ * The id of the stream's one destination, off `Destinations[0].DestinationId`.
114
+ * `UpdateDestination` requires it and there is nowhere else to get it: it is generated
115
+ * by the service (`destinationId-000000000001` in AWS's own example) and is not
116
+ * derivable from anything the caller knows.
117
+ */
118
+ readonly destinationId?: string | undefined;
119
+ /**
120
+ * The live `AppendOnly` flag off `Destinations[0].IcebergDestinationDescription`.
121
+ * Read back rather than assumed: it is what the stream node compares against
122
+ * {@link STREAM_APPEND_ONLY} to decide whether the destination needs reconciling at
123
+ * all. Absent for a destination that is not an Iceberg one, or for a service response
124
+ * that omits the flag.
125
+ */
126
+ readonly appendOnly?: boolean | undefined;
127
+ }
128
+ /**
129
+ * The current version and destination id `UpdateDestination` conditions on - the two
130
+ * halves of {@link DeliveryStreamStatus} that a caller must have read back off the live
131
+ * stream before it can update one. Taken as a pair rather than two positional strings
132
+ * so a call site cannot silently transpose them: both are opaque service-generated
133
+ * strings, so a swap would typecheck and fail only on the wire.
134
+ */
135
+ export interface DestinationVersion {
136
+ /** `DeliveryStreamDescription.VersionId`, sent as `CurrentDeliveryStreamVersionId`. */
137
+ readonly versionId: string;
138
+ /** `Destinations[].DestinationId`, sent as `DestinationId`. */
139
+ readonly destinationId: string;
140
+ }
141
+ /** Amazon Data Firehose control-plane client, over the shared SigV4 transport. */
142
+ export declare class FirehoseClient {
143
+ private readonly client;
144
+ constructor(client: SigningClient);
145
+ private call;
146
+ /**
147
+ * Create the delivery stream with its Iceberg destination. Idempotent: a stream of
148
+ * the same name already existing is not an error (see `isStreamAlreadyExists`). Its
149
+ * destination is not reconciled against `destination` on that path - changing a live
150
+ * stream's destination is {@link updateDestination}, a separate operation, and the
151
+ * node that owns the stream decides between updating and replacing it.
152
+ *
153
+ * `tags` are sent in the create request itself when non-empty, saving a round trip
154
+ * and matching how `packages/core/src/aws/logs.ts:41` and `secretsmanager.ts:47-49`
155
+ * tag on create. They are *not* applied on the already-exists path, because the
156
+ * create that would have carried them failed - which is one of the reasons
157
+ * `tagDeliveryStream` exists as its own operation.
158
+ *
159
+ * Returns `void`, discarding the response's `DeliveryStreamARN`: it is unavailable on
160
+ * the already-exists path (the error body carries no ARN), so returning it from only
161
+ * one of the two branches would be a false economy, and `describeDeliveryStream` is
162
+ * name-keyed, so a caller that needs the ARN reads it back.
163
+ */
164
+ createDeliveryStream(name: string, destination: IcebergDestinationInput, tags?: ResourceTags): Promise<void>;
165
+ /**
166
+ * The stream's name, ARN and lifecycle state, with the service's last failure detail
167
+ * when it reports one. `undefined` when no such stream exists - Firehose answers an
168
+ * absent stream with `ResourceNotFoundException` (at HTTP 400, not 404, which is why
169
+ * the narrowing reads the code rather than the status), mirroring
170
+ * `packages/core/src/aws/secretsmanager.ts:78-89`.
171
+ */
172
+ describeDeliveryStream(name: string): Promise<DeliveryStreamStatus | undefined>;
173
+ /**
174
+ * Reconfigure the live stream's destination in place, leaving its ARN - and therefore
175
+ * the CloudFront log delivery pointed at it - untouched. The alternative is deleting
176
+ * and recreating the stream, which cascades: a new stream carries a new ARN, so the
177
+ * delivery has to be repointed, and every record in flight during the gap is lost.
178
+ *
179
+ * **Every failure is rethrown; nothing is swallowed here.** `ResourceInUseException`
180
+ * on this operation is *not* the already-exists it is on `CreateDeliveryStream` (see
181
+ * {@link isStreamAlreadyExists}) - the reference defines it here as "the resource is
182
+ * already in use and not available for this operation", i.e. the stream is busy. So
183
+ * `isStreamAlreadyExists` is deliberately **not** reused, and neither is any other
184
+ * narrowing: whether a rejection means "fall back to replacing the stream" is the
185
+ * caller's decision, not this client's, precisely because AWS's own documentation
186
+ * contradicts itself on whether `AppendOnly` is settable after creation. Swallowing
187
+ * anything here would turn a refused update into a silent no-op.
188
+ *
189
+ * Three wire details are load-bearing and each is verified against the reference:
190
+ *
191
+ * - the current version travels as **`CurrentDeliveryStreamVersionId`**, not as the
192
+ * `VersionId` that `DescribeDeliveryStream` answers with. Same value, different key;
193
+ * sending `VersionId` would be dropped as an unknown member and the request rejected
194
+ * for a missing required one.
195
+ * - `DestinationId` is required and comes from `Destinations[].DestinationId` - the
196
+ * service generates it, so {@link describeDeliveryStream} is the only source.
197
+ * - the destination is sent under **`IcebergDestinationUpdate`**, whose shape is a
198
+ * subset of the `IcebergDestinationConfiguration` {@link buildIcebergDestination}
199
+ * already builds - including, unusually, its `S3Configuration` key. Every *other*
200
+ * `*DestinationUpdate` in this request body renames that member to `S3Update`; the
201
+ * Iceberg one does not. That is why the create's builder is reused verbatim rather
202
+ * than a second, nearly-identical update builder being written: one builder is one
203
+ * place for every spelling, and the two payloads are genuinely the same object.
204
+ *
205
+ * The service merges what is sent with what exists when the destination type is
206
+ * unchanged, so sending the whole destination (rather than only the changed field) is
207
+ * both allowed and what makes this a reconcile: whatever drifted converges.
208
+ */
209
+ updateDestination(name: string, destination: IcebergDestinationInput, current: DestinationVersion): Promise<void>;
210
+ /**
211
+ * Delete the stream. No-op when it does not exist, so teardown is re-runnable.
212
+ *
213
+ * Every other failure is rethrown with context - including `ResourceInUseException`,
214
+ * which on this operation means the stream is still `CREATING` and cannot be deleted
215
+ * yet, not that it is already gone (see `isStreamAlreadyExists`).
216
+ *
217
+ * `AllowForceDelete` is deliberately not sent: it exists only to abandon a KMS grant
218
+ * Firehose cannot retire, and this stream is created with no customer-managed key, so
219
+ * setting it would suppress a class of failure that cannot arise here.
220
+ */
221
+ deleteDeliveryStream(name: string): Promise<void>;
222
+ /**
223
+ * Add or replace tags on an existing stream. An empty map skips the call entirely
224
+ * rather than sending an empty list: `TagDeliveryStream` requires `Tags` and rejects
225
+ * a list of fewer than one item, so the request could only fail.
226
+ */
227
+ tagDeliveryStream(name: string, tags: ResourceTags): Promise<void>;
228
+ }
@@ -0,0 +1,347 @@
1
+ import { AwsError, } from 'blogwright-core';
2
+ import { rethrowWithContext } from './errors.js';
3
+ /**
4
+ * Amazon Data Firehose control-plane client - create/describe/update-destination/delete
5
+ * and tagging for the one delivery stream this plugin owns (the `firehose` API, AWS
6
+ * JSON 1.1).
7
+ * It lives in `blogwright-analytics`, not in core: core's `SIGNING_NAMES` gains no
8
+ * `firehose` key, and every request signs through the `{ service: 'firehose',
9
+ * signingName: 'firehose' }` descriptor the plugin transport seam accepts (see
10
+ * `packages/core/src/aws/endpoint.ts`'s `ServiceDescriptor`), which resolves to the
11
+ * canonical `firehose.<region>.amazonaws.com` host.
12
+ *
13
+ * Protocol: AWS JSON 1.1 - every operation is `POST /` with
14
+ * `content-type: application/x-amz-json-1.1` and an
15
+ * `x-amz-target: Firehose_20150804.<Operation>` header, exactly as
16
+ * `packages/core/src/aws/secretsmanager.ts` and `logs.ts` do for their services.
17
+ * `Firehose_20150804` is the service's own target prefix, confirmed against the
18
+ * `X-Amz-Target` line of every sample request in the Firehose API reference.
19
+ *
20
+ * Operation names and body shapes below are verified field by field against the
21
+ * Firehose API reference (`CreateDeliveryStream`, `DescribeDeliveryStream`,
22
+ * `UpdateDestination`, `DeleteDeliveryStream`, `TagDeliveryStream`, and the
23
+ * `IcebergDestinationConfiguration`, `IcebergDestinationUpdate`, `CatalogConfiguration`,
24
+ * `S3DestinationConfiguration`, `DestinationTableConfiguration`, `DeliveryStreamDescription`,
25
+ * `DestinationDescription`, `IcebergDestinationDescription`, `BufferingHints`, `Processor`
26
+ * and `ProcessorParameter` shapes they nest). No SDK
27
+ * validates them here and transport-mocked tests can only assert the body this module
28
+ * itself builds, so the reference is the only thing that catches a wrong key - and a
29
+ * wrong key produces a silently misconfigured stream, not an error. Four spellings are
30
+ * easy to get wrong and are pinned deliberately: the ARN-bearing keys are `RoleARN`,
31
+ * `BucketARN` and `CatalogARN` (upper-case `ARN`), while the Lambda processor's
32
+ * parameter is `LambdaArn` (mixed case) - one of the eleven literals
33
+ * `ProcessorParameter.ParameterName` accepts; `UpdateDestination` names the current
34
+ * version `CurrentDeliveryStreamVersionId`, **not** the `VersionId` that
35
+ * `DeliveryStreamDescription` answers with (the value is the same, the key is not); and
36
+ * `IcebergDestinationUpdate` nests its error bucket under `S3Configuration`, where every
37
+ * other `*DestinationUpdate` in the same request body nests one under `S3Update` (see
38
+ * {@link FirehoseClient.updateDestination}).
39
+ *
40
+ * The floci emulator does not implement this service, so it is covered by transport
41
+ * mocks in tests.
42
+ */
43
+ const SERVICE = { service: 'firehose', signingName: 'firehose' };
44
+ /** The service's AWS-JSON target prefix; every `x-amz-target` is `${TARGET}.<Operation>`. */
45
+ const TARGET = 'Firehose_20150804';
46
+ /**
47
+ * The stream's source type. CloudFront's vended log delivery puts records into the
48
+ * stream directly rather than through a Kinesis stream, an MSK cluster or a database,
49
+ * so `DirectPut` is the only correct value of the four `DeliveryStreamType` accepts.
50
+ * Sent explicitly rather than relying on the service default, so the request states
51
+ * the source model it depends on.
52
+ */
53
+ const STREAM_TYPE = 'DirectPut';
54
+ /**
55
+ * `page_views` is insert-only by design, so the stream is created append-only - the
56
+ * analytics change spec settles this ("*The stream is created `AppendOnly`.*"), and it
57
+ * also lets Firehose scale the stream's throughput limit automatically.
58
+ *
59
+ * A module constant rather than a field of {@link IcebergDestinationInput}: this client
60
+ * builds exactly one shape of destination, and both operations that send one -
61
+ * `CreateDeliveryStream` and `UpdateDestination` - send this same value, so there is no
62
+ * call site that could ever ask for anything else. Whether the flag is mutable after
63
+ * creation is unsettled between two AWS documentation pages, and the node that
64
+ * reconciles the stream (not this client) is where that question is answered - which is
65
+ * why the constant is **exported**: the node compares it against the flag
66
+ * {@link DeliveryStreamStatus.appendOnly} read back off the live stream, and a second
67
+ * copy of the desired value in `nodes.ts` would let the two drift apart in silence.
68
+ */
69
+ export const STREAM_APPEND_ONLY = true;
70
+ /** The `Processor.Type` for a record-transforming Lambda; one of six literals the field accepts. */
71
+ const LAMBDA_PROCESSOR = 'Lambda';
72
+ /** The `ProcessorParameter.ParameterName` carrying the transform function's ARN. Mixed case (`Arn`), unlike the `RoleARN`/`BucketARN`/`CatalogARN` keys. */
73
+ const LAMBDA_ARN_PARAMETER = 'LambdaArn';
74
+ function toDeliveryState(status) {
75
+ switch (status) {
76
+ case 'CREATING':
77
+ return 'creating';
78
+ case 'CREATING_FAILED':
79
+ return 'create-failed';
80
+ case 'ACTIVE':
81
+ return 'active';
82
+ case 'DELETING':
83
+ return 'deleting';
84
+ case 'DELETING_FAILED':
85
+ return 'delete-failed';
86
+ default:
87
+ return 'unknown';
88
+ }
89
+ }
90
+ /** Flatten `FailureDescription`'s `{ Type, Details }` into one printable line, or undefined when the service reported neither. */
91
+ function formatFailure(failure) {
92
+ const parts = [failure?.Type, failure?.Details].filter((part) => typeof part === 'string' && part.length > 0);
93
+ return parts.length > 0 ? parts.join(': ') : undefined;
94
+ }
95
+ /**
96
+ * True when a `CreateDeliveryStream` failure means the stream already exists, so a
97
+ * re-run of the create is a no-op rather than an error.
98
+ *
99
+ * This predicate is local to this package deliberately. Firehose signals a duplicate
100
+ * delivery stream with `ResourceInUseException`, and `AwsError.isAlreadyExists`
101
+ * (`packages/core/src/aws/errors.ts:32`) tests `code` against
102
+ * `/AlreadyExists|BucketAlreadyOwnedByYou|EntityAlreadyExists|Conflict/i` - which that
103
+ * name matches on none of its four alternatives. Unlike the S3 Tables gap next door in
104
+ * `s3tables.ts`, `code` itself is correct here: Firehose is an AWS-JSON service and its
105
+ * error body carries `{"__type":"ResourceInUseException","message":...}`, which core's
106
+ * `parseError` reads. So this is a predicate-breadth gap, not a header-parsing one, and
107
+ * the fix belongs here rather than in core's regex, which is shared with the site's own
108
+ * bootstrap and would start swallowing `ResourceInUseException` on every other service.
109
+ * `packages/core/src/aws/secretsmanager.ts:53-54` widens the same predicate the same way
110
+ * for its own `ResourceExistsException`.
111
+ *
112
+ * Named for the create path rather than as a general `isAlreadyExists` because the same
113
+ * exception means something else on delete: `DeleteDeliveryStream` answers
114
+ * `ResourceInUseException` when the stream is still `CREATING` and therefore cannot be
115
+ * deleted yet. Swallowing that would report a live stream as torn down, so
116
+ * `deleteDeliveryStream` narrows on `isNotFound` only and lets this one through.
117
+ *
118
+ * The status code is no help either way: every one of these is HTTP 400, so
119
+ * `statusCode` cannot separate an already-exists from a validation error the way it can
120
+ * for S3 Tables' 409.
121
+ */
122
+ function isStreamAlreadyExists(err) {
123
+ return err instanceof AwsError && (err.isAlreadyExists || err.code === 'ResourceInUseException');
124
+ }
125
+ /** Encode `ResourceTags` as the service's `[{ Key, Value }]` tag list. */
126
+ function toTagList(tags) {
127
+ return Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));
128
+ }
129
+ /** Translate the client's `IcebergDestinationInput` into the wire's `IcebergDestinationConfiguration`. */
130
+ function buildIcebergDestination(input) {
131
+ return {
132
+ RoleARN: input.roleArn,
133
+ CatalogConfiguration: { CatalogARN: input.catalogArn },
134
+ S3Configuration: {
135
+ // BucketARN and RoleARN are both required members of S3DestinationConfiguration;
136
+ // omitting either is rejected at create time.
137
+ BucketARN: input.errorBucketArn,
138
+ RoleARN: input.roleArn,
139
+ ErrorOutputPrefix: input.errorOutputPrefix,
140
+ },
141
+ AppendOnly: STREAM_APPEND_ONLY,
142
+ BufferingHints: {
143
+ IntervalInSeconds: input.bufferIntervalSeconds,
144
+ SizeInMBs: input.bufferSizeMb,
145
+ },
146
+ // A single-element list: one stream writes to one table (the spec declines to point
147
+ // two streams at one Iceberg table). Without this list Firehose would expect each
148
+ // record to carry its own routing metadata, which the transform Lambda does not emit.
149
+ DestinationTableConfigurationList: [
150
+ {
151
+ DestinationDatabaseName: input.namespace,
152
+ DestinationTableName: input.tableName,
153
+ S3ErrorOutputPrefix: input.errorOutputPrefix,
154
+ },
155
+ ],
156
+ ProcessingConfiguration: {
157
+ Enabled: true,
158
+ Processors: [
159
+ {
160
+ Type: LAMBDA_PROCESSOR,
161
+ Parameters: [
162
+ { ParameterName: LAMBDA_ARN_PARAMETER, ParameterValue: input.transformLambdaArn },
163
+ ],
164
+ },
165
+ ],
166
+ },
167
+ };
168
+ }
169
+ /** Amazon Data Firehose control-plane client, over the shared SigV4 transport. */
170
+ export class FirehoseClient {
171
+ client;
172
+ constructor(client) {
173
+ this.client = client;
174
+ }
175
+ async call(op, payload) {
176
+ const res = await this.client.send({
177
+ service: SERVICE,
178
+ method: 'POST',
179
+ path: '/',
180
+ headers: {
181
+ 'content-type': 'application/x-amz-json-1.1',
182
+ 'x-amz-target': `${TARGET}.${op}`,
183
+ },
184
+ body: JSON.stringify(payload),
185
+ });
186
+ const text = res.text();
187
+ return (text ? JSON.parse(text) : {});
188
+ }
189
+ /**
190
+ * Create the delivery stream with its Iceberg destination. Idempotent: a stream of
191
+ * the same name already existing is not an error (see `isStreamAlreadyExists`). Its
192
+ * destination is not reconciled against `destination` on that path - changing a live
193
+ * stream's destination is {@link updateDestination}, a separate operation, and the
194
+ * node that owns the stream decides between updating and replacing it.
195
+ *
196
+ * `tags` are sent in the create request itself when non-empty, saving a round trip
197
+ * and matching how `packages/core/src/aws/logs.ts:41` and `secretsmanager.ts:47-49`
198
+ * tag on create. They are *not* applied on the already-exists path, because the
199
+ * create that would have carried them failed - which is one of the reasons
200
+ * `tagDeliveryStream` exists as its own operation.
201
+ *
202
+ * Returns `void`, discarding the response's `DeliveryStreamARN`: it is unavailable on
203
+ * the already-exists path (the error body carries no ARN), so returning it from only
204
+ * one of the two branches would be a false economy, and `describeDeliveryStream` is
205
+ * name-keyed, so a caller that needs the ARN reads it back.
206
+ */
207
+ async createDeliveryStream(name, destination, tags) {
208
+ try {
209
+ await this.call('CreateDeliveryStream', {
210
+ DeliveryStreamName: name,
211
+ DeliveryStreamType: STREAM_TYPE,
212
+ IcebergDestinationConfiguration: buildIcebergDestination(destination),
213
+ ...(tags && Object.keys(tags).length > 0 ? { Tags: toTagList(tags) } : {}),
214
+ });
215
+ }
216
+ catch (err) {
217
+ if (isStreamAlreadyExists(err))
218
+ return;
219
+ rethrowWithContext(err, 'createDeliveryStream', name);
220
+ }
221
+ }
222
+ /**
223
+ * The stream's name, ARN and lifecycle state, with the service's last failure detail
224
+ * when it reports one. `undefined` when no such stream exists - Firehose answers an
225
+ * absent stream with `ResourceNotFoundException` (at HTTP 400, not 404, which is why
226
+ * the narrowing reads the code rather than the status), mirroring
227
+ * `packages/core/src/aws/secretsmanager.ts:78-89`.
228
+ */
229
+ async describeDeliveryStream(name) {
230
+ try {
231
+ const out = await this.call('DescribeDeliveryStream', {
232
+ DeliveryStreamName: name,
233
+ });
234
+ const description = out.DeliveryStreamDescription;
235
+ const failure = formatFailure(description?.FailureDescription);
236
+ // The stream's single destination. `Destinations` is a list because the API shape
237
+ // is shared with services that fan out; a Firehose stream has exactly one, so the
238
+ // first element is it. `noUncheckedIndexedAccess` is why this is `undefined`-typed.
239
+ const destination = description?.Destinations?.[0];
240
+ const versionId = description?.VersionId;
241
+ const destinationId = destination?.DestinationId;
242
+ const appendOnly = destination?.IcebergDestinationDescription?.AppendOnly;
243
+ return {
244
+ name: description?.DeliveryStreamName ?? name,
245
+ arn: description?.DeliveryStreamARN ?? '',
246
+ state: toDeliveryState(description?.DeliveryStreamStatus),
247
+ ...(failure !== undefined ? { failure } : {}),
248
+ ...(versionId !== undefined ? { versionId } : {}),
249
+ ...(destinationId !== undefined ? { destinationId } : {}),
250
+ ...(appendOnly !== undefined ? { appendOnly } : {}),
251
+ };
252
+ }
253
+ catch (err) {
254
+ if (err instanceof AwsError && err.isNotFound)
255
+ return undefined;
256
+ rethrowWithContext(err, 'describeDeliveryStream', name);
257
+ }
258
+ }
259
+ /**
260
+ * Reconfigure the live stream's destination in place, leaving its ARN - and therefore
261
+ * the CloudFront log delivery pointed at it - untouched. The alternative is deleting
262
+ * and recreating the stream, which cascades: a new stream carries a new ARN, so the
263
+ * delivery has to be repointed, and every record in flight during the gap is lost.
264
+ *
265
+ * **Every failure is rethrown; nothing is swallowed here.** `ResourceInUseException`
266
+ * on this operation is *not* the already-exists it is on `CreateDeliveryStream` (see
267
+ * {@link isStreamAlreadyExists}) - the reference defines it here as "the resource is
268
+ * already in use and not available for this operation", i.e. the stream is busy. So
269
+ * `isStreamAlreadyExists` is deliberately **not** reused, and neither is any other
270
+ * narrowing: whether a rejection means "fall back to replacing the stream" is the
271
+ * caller's decision, not this client's, precisely because AWS's own documentation
272
+ * contradicts itself on whether `AppendOnly` is settable after creation. Swallowing
273
+ * anything here would turn a refused update into a silent no-op.
274
+ *
275
+ * Three wire details are load-bearing and each is verified against the reference:
276
+ *
277
+ * - the current version travels as **`CurrentDeliveryStreamVersionId`**, not as the
278
+ * `VersionId` that `DescribeDeliveryStream` answers with. Same value, different key;
279
+ * sending `VersionId` would be dropped as an unknown member and the request rejected
280
+ * for a missing required one.
281
+ * - `DestinationId` is required and comes from `Destinations[].DestinationId` - the
282
+ * service generates it, so {@link describeDeliveryStream} is the only source.
283
+ * - the destination is sent under **`IcebergDestinationUpdate`**, whose shape is a
284
+ * subset of the `IcebergDestinationConfiguration` {@link buildIcebergDestination}
285
+ * already builds - including, unusually, its `S3Configuration` key. Every *other*
286
+ * `*DestinationUpdate` in this request body renames that member to `S3Update`; the
287
+ * Iceberg one does not. That is why the create's builder is reused verbatim rather
288
+ * than a second, nearly-identical update builder being written: one builder is one
289
+ * place for every spelling, and the two payloads are genuinely the same object.
290
+ *
291
+ * The service merges what is sent with what exists when the destination type is
292
+ * unchanged, so sending the whole destination (rather than only the changed field) is
293
+ * both allowed and what makes this a reconcile: whatever drifted converges.
294
+ */
295
+ async updateDestination(name, destination, current) {
296
+ try {
297
+ await this.call('UpdateDestination', {
298
+ DeliveryStreamName: name,
299
+ CurrentDeliveryStreamVersionId: current.versionId,
300
+ DestinationId: current.destinationId,
301
+ IcebergDestinationUpdate: buildIcebergDestination(destination),
302
+ });
303
+ }
304
+ catch (err) {
305
+ rethrowWithContext(err, 'updateDestination', name);
306
+ }
307
+ }
308
+ /**
309
+ * Delete the stream. No-op when it does not exist, so teardown is re-runnable.
310
+ *
311
+ * Every other failure is rethrown with context - including `ResourceInUseException`,
312
+ * which on this operation means the stream is still `CREATING` and cannot be deleted
313
+ * yet, not that it is already gone (see `isStreamAlreadyExists`).
314
+ *
315
+ * `AllowForceDelete` is deliberately not sent: it exists only to abandon a KMS grant
316
+ * Firehose cannot retire, and this stream is created with no customer-managed key, so
317
+ * setting it would suppress a class of failure that cannot arise here.
318
+ */
319
+ async deleteDeliveryStream(name) {
320
+ try {
321
+ await this.call('DeleteDeliveryStream', { DeliveryStreamName: name });
322
+ }
323
+ catch (err) {
324
+ if (err instanceof AwsError && err.isNotFound)
325
+ return;
326
+ rethrowWithContext(err, 'deleteDeliveryStream', name);
327
+ }
328
+ }
329
+ /**
330
+ * Add or replace tags on an existing stream. An empty map skips the call entirely
331
+ * rather than sending an empty list: `TagDeliveryStream` requires `Tags` and rejects
332
+ * a list of fewer than one item, so the request could only fail.
333
+ */
334
+ async tagDeliveryStream(name, tags) {
335
+ if (Object.keys(tags).length === 0)
336
+ return;
337
+ try {
338
+ await this.call('TagDeliveryStream', {
339
+ DeliveryStreamName: name,
340
+ Tags: toTagList(tags),
341
+ });
342
+ }
343
+ catch (err) {
344
+ rethrowWithContext(err, 'tagDeliveryStream', name);
345
+ }
346
+ }
347
+ }