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,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
|
+
}
|