@fjall/components-infrastructure 0.100.0 → 0.102.0

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.
@@ -4,7 +4,7 @@ import { type IBucket } from "aws-cdk-lib/aws-s3";
4
4
  import { Construct } from "constructs";
5
5
  import { Secret } from "../../resources/aws/secrets/secret.js";
6
6
  import { type ClickHouseSchemaAdmin, type ProfileSpec } from "../../resources/aws/database/clickhouseSchemas.js";
7
- import { type IClickHouseDatabase } from "./interfaces/database.js";
7
+ import { type ClickHouseMigrationsConfig, type IClickHouseDatabase } from "./interfaces/database.js";
8
8
  import { type ISecurityGroupConnector } from "./interfaces/connector.js";
9
9
  import { type MigrationContributions } from "./interfaces/migrationContributor.js";
10
10
  /**
@@ -95,6 +95,15 @@ export interface ClickHouseDatabaseProps {
95
95
  * non-snake_case names.
96
96
  */
97
97
  profiles?: Record<string, ProfileSpec>;
98
+ /**
99
+ * Schema-version gate. When set, every container of every service whose
100
+ * `connections:` includes this database receives `EXPECTED_CH_SCHEMA_VERSION`
101
+ * baked into its env at synth, unless the service sets `schemaGate: false`.
102
+ * The default resolver picks the lexicographically-latest `*.sql` filename
103
+ * under `dir` (skipping `*.dev.sql`), matching what `runSqlMigrations`
104
+ * records in `_schema_migrations.ch_version`.
105
+ */
106
+ migrations?: ClickHouseMigrationsConfig;
98
107
  }
99
108
  /**
100
109
  * ClickHouse analytics database wrapper implementing IClickHouseDatabase.
@@ -134,6 +143,8 @@ export declare class ClickHouseDatabase extends Construct implements IClickHouse
134
143
  getDatabaseName(): string;
135
144
  getBackupBucket(): IBucket;
136
145
  getColdTierBucket(): IBucket | undefined;
146
+ getMigrationsConfig(): ClickHouseMigrationsConfig | undefined;
147
+ getExpectedSchemaVersion(): string | undefined;
137
148
  grantConnect(grantee: IConnectable): void;
138
149
  /**
139
150
  * Migration contributions for a task connecting to this ClickHouse cluster
@@ -1,4 +1,4 @@
1
- import { CLICKHOUSE_MANAGED_USERS_ENV } from "@fjall/util/migration";
1
+ import { CLICKHOUSE_MANAGED_USERS_ENV, pickLatestClickHouseMigration } from "@fjall/util/migration";
2
2
  import { Annotations, CfnOutput, Duration, Fn, Stack } from "aws-cdk-lib";
3
3
  import { Connections, Port, UserData } from "aws-cdk-lib/aws-ec2";
4
4
  import { Monitoring } from "aws-cdk-lib/aws-autoscaling";
@@ -89,6 +89,7 @@ export class ClickHouseDatabase extends Construct {
89
89
  #managedPasswordNames;
90
90
  #backupBucket;
91
91
  #coldTierBucket;
92
+ #migrationsConfig;
92
93
  constructor(scope, id, props) {
93
94
  super(scope, id);
94
95
  this.id = id;
@@ -185,6 +186,7 @@ export class ClickHouseDatabase extends Construct {
185
186
  : undefined;
186
187
  this.#backupBucket = backupBucket;
187
188
  this.#coldTierBucket = coldTierBucket;
189
+ this.#migrationsConfig = props.migrations;
188
190
  const backupTaskLogGroup = backupEnabled
189
191
  ? new LogGroup(this, "ClickHouseBackupTaskLogGroup", {
190
192
  retention: RetentionDays.TWO_WEEKS
@@ -518,6 +520,20 @@ export class ClickHouseDatabase extends Construct {
518
520
  getColdTierBucket() {
519
521
  return this.#coldTierBucket;
520
522
  }
523
+ getMigrationsConfig() {
524
+ return this.#migrationsConfig;
525
+ }
526
+ getExpectedSchemaVersion() {
527
+ const config = this.#migrationsConfig;
528
+ if (config === undefined)
529
+ return undefined;
530
+ const resolver = config.versionResolver ?? pickLatestClickHouseMigration;
531
+ const version = resolver(config.dir);
532
+ if (version.trim() === "") {
533
+ throw new Error(`ClickHouseDatabase '${this.id}': migrations.versionResolver returned an empty string for dir '${config.dir}' — refusing to inject an empty EXPECTED_CH_SCHEMA_VERSION.`);
534
+ }
535
+ return version;
536
+ }
521
537
  grantConnect(grantee) {
522
538
  this.connections.allowDefaultPortFrom(grantee);
523
539
  this.connections.allowFrom(grantee, Port.tcp(CLICKHOUSE_NATIVE_PORT));
@@ -48,7 +48,7 @@ export declare function expandMigrationsSugar(service: EcsServiceConfig, userCon
48
48
  * themselves and the resolved value differs.
49
49
  * @internal Exported for testing only
50
50
  */
51
- export declare function buildContainerConfigs(service: EcsServiceConfig, schemaVersionEnv?: Record<string, string>, annotationsScope?: Construct): EcsClusterProps["services"][number]["containers"];
51
+ export declare function buildContainerConfigs(service: EcsServiceConfig, schemaVersionEnv?: Record<string, string>, annotationsScope?: Construct, chSchemaVersionEnv?: Record<string, string>): EcsClusterProps["services"][number]["containers"];
52
52
  /**
53
53
  * Resolved scaling configuration for an ECS service.
54
54
  * @internal Exported for testing only
@@ -92,6 +92,18 @@ export declare class EcsCompute extends Construct implements IEcsCompute {
92
92
  * hardcoding one.
93
93
  */
94
94
  private resolveSchemaVersionEnv;
95
+ /**
96
+ * ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
97
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service is
98
+ * not gated.
99
+ *
100
+ * - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
101
+ * covers BOTH PG and CH gates)
102
+ * - No migrated CH in connections → returns `undefined`
103
+ * - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
104
+ * - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
105
+ */
106
+ private resolveClickHouseSchemaVersionEnv;
95
107
  private materialiseScheduledTasks;
96
108
  private buildScheduledTaskDefinition;
97
109
  /**
@@ -7,7 +7,7 @@ import { Secret } from "aws-cdk-lib/aws-secretsmanager";
7
7
  import { StringParameter } from "aws-cdk-lib/aws-ssm";
8
8
  import { Construct } from "constructs";
9
9
  import { resolveAlertsTopic } from "../../utils/resolveAlertsTopic.js";
10
- import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
10
+ import { EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV, isClickHouseDatabase, isDatabase, isRelationalDatabase } from "./interfaces/database.js";
11
11
  import { isConnectionConfig } from "../../utils/connector.js";
12
12
  import { isMigrationContributor } from "./interfaces/migrationContributor.js";
13
13
  import App from "../../app.js";
@@ -274,6 +274,37 @@ function resolveMigrationDatabaseForService(service) {
274
274
  }
275
275
  return matches[0];
276
276
  }
277
+ /**
278
+ * ClickHouse mirror of `resolveMigrationDatabaseForService`. Two-CH-per-service
279
+ * is rejected for the same reason: the gate can only inject one
280
+ * `EXPECTED_CH_SCHEMA_VERSION`.
281
+ */
282
+ function resolveClickHouseDatabaseForService(service) {
283
+ const connections = service.connections;
284
+ if (connections === undefined || connections.length === 0)
285
+ return undefined;
286
+ const matches = [];
287
+ for (const spec of connections) {
288
+ const resource = isConnectionConfig(spec) ? spec.resource : spec;
289
+ if (!isDatabase(resource))
290
+ continue;
291
+ if (!isClickHouseDatabase(resource))
292
+ continue;
293
+ if (resource.getMigrationsConfig() !== undefined)
294
+ matches.push(resource);
295
+ }
296
+ if (matches.length === 0)
297
+ return undefined;
298
+ if (matches.length > 1) {
299
+ const names = matches.map((d) => d.node.id).join(", ");
300
+ throw new Error(`Service '${service.name}': connections include two or more ClickHouse ` +
301
+ `databases declaring \`migrations:\` (${names}). The schema-version ` +
302
+ `gate cannot inject ${EXPECTED_CH_SCHEMA_VERSION_ENV} unambiguously. ` +
303
+ `Split the service so each consumes a single migrated database, or ` +
304
+ `set \`schemaGate: false\` and inject the env manually.`);
305
+ }
306
+ return matches[0];
307
+ }
277
308
  function getResourceLabel(resource) {
278
309
  if (resource !== null &&
279
310
  typeof resource === "object" &&
@@ -436,7 +467,7 @@ function mergeContributionsIntoSeparateTaskDef(separateTaskDef, contributions) {
436
467
  * themselves and the resolved value differs.
437
468
  * @internal Exported for testing only
438
469
  */
439
- export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope) {
470
+ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScope, chSchemaVersionEnv) {
440
471
  const userContainers = service.containers && service.containers.length > 0
441
472
  ? service.containers
442
473
  : undefined;
@@ -464,19 +495,43 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
464
495
  };
465
496
  }
466
497
  const merged = { ...(authored ?? {}) };
467
- for (const [key, value] of Object.entries(schemaVersionEnv)) {
468
- if (merged[key] === undefined)
469
- merged[key] = value;
498
+ if (schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV] !== undefined) {
499
+ merged[EXPECTED_SCHEMA_VERSION_ENV] =
500
+ schemaVersionEnv[EXPECTED_SCHEMA_VERSION_ENV];
501
+ }
502
+ if (schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV] !== undefined) {
503
+ merged[EXPECTED_SCHEMA_VERSION_TOOL_ENV] =
504
+ schemaVersionEnv[EXPECTED_SCHEMA_VERSION_TOOL_ENV];
470
505
  }
471
506
  return merged;
472
507
  };
508
+ const mergeChSchemaEnv = (authored) => {
509
+ if (chSchemaVersionEnv === undefined)
510
+ return authored;
511
+ const authoredValue = authored?.[EXPECTED_CH_SCHEMA_VERSION_ENV];
512
+ const resolvedValue = chSchemaVersionEnv[EXPECTED_CH_SCHEMA_VERSION_ENV];
513
+ if (authoredValue !== undefined) {
514
+ if (authoredValue !== resolvedValue && annotationsScope !== undefined) {
515
+ Annotations.of(annotationsScope).addWarning(`Service '${service.name}': author-set ${EXPECTED_CH_SCHEMA_VERSION_ENV}` +
516
+ `='${authoredValue}' overrides the value resolved from the connected ` +
517
+ `ClickHouse database's \`migrations:\` config ('${resolvedValue}'). The ` +
518
+ `author value wins; set \`schemaGate: false\` to silence this warning.`);
519
+ }
520
+ return authored;
521
+ }
522
+ return {
523
+ ...(authored ?? {}),
524
+ [EXPECTED_CH_SCHEMA_VERSION_ENV]: resolvedValue
525
+ };
526
+ };
527
+ const mergeAllSchemaEnv = (authored) => mergeChSchemaEnv(mergeSchemaEnv(authored));
473
528
  if (expanded) {
474
529
  return expanded.map((c, index) => {
475
530
  return {
476
531
  name: c.name || `${service.name}Container${index > 0 ? index : ""}`,
477
532
  image: c.image,
478
533
  port: c.port,
479
- environment: mergeSchemaEnv(c.environment),
534
+ environment: mergeAllSchemaEnv(c.environment),
480
535
  secrets: c.secrets,
481
536
  secretsImport: c.secretsImport,
482
537
  command: c.command,
@@ -490,7 +545,7 @@ export function buildContainerConfigs(service, schemaVersionEnv, annotationsScop
490
545
  };
491
546
  });
492
547
  }
493
- const fallbackEnv = mergeSchemaEnv(undefined);
548
+ const fallbackEnv = mergeAllSchemaEnv(undefined);
494
549
  return [
495
550
  {
496
551
  name: `${service.name}Container`,
@@ -562,7 +617,8 @@ export class EcsCompute extends Construct {
562
617
  this.appName = props.appName;
563
618
  const services = props.services.map((service) => {
564
619
  const schemaVersionEnv = this.resolveSchemaVersionEnv(service);
565
- const containers = buildContainerConfigs(service, schemaVersionEnv, this);
620
+ const chSchemaVersionEnv = this.resolveClickHouseSchemaVersionEnv(service);
621
+ const containers = buildContainerConfigs(service, schemaVersionEnv, this, chSchemaVersionEnv);
566
622
  const { scalingType, minCapacity, maxCapacity } = resolveScalingConfig(service.scaling);
567
623
  const cloudMapService = service.serviceDiscovery !== undefined
568
624
  ? App.getInstance().registerService({
@@ -670,6 +726,30 @@ export class EcsCompute extends Construct {
670
726
  [EXPECTED_SCHEMA_VERSION_TOOL_ENV]: config.tool
671
727
  };
672
728
  }
729
+ /**
730
+ * ClickHouse mirror of `resolveSchemaVersionEnv`. Returns the
731
+ * `EXPECTED_CH_SCHEMA_VERSION` env entry, or `undefined` when the service is
732
+ * not gated.
733
+ *
734
+ * - `schemaGate: false` → returns `undefined` (auditable opt-out — same flag
735
+ * covers BOTH PG and CH gates)
736
+ * - No migrated CH in connections → returns `undefined`
737
+ * - Exactly one migrated CH → returns `{ EXPECTED_CH_SCHEMA_VERSION }`
738
+ * - Two or more migrated CHs → throws via `resolveClickHouseDatabaseForService`
739
+ */
740
+ resolveClickHouseSchemaVersionEnv(service) {
741
+ if (service.schemaGate === false)
742
+ return undefined;
743
+ const db = resolveClickHouseDatabaseForService(service);
744
+ if (db === undefined)
745
+ return undefined;
746
+ const version = db.getExpectedSchemaVersion();
747
+ if (version === undefined)
748
+ return undefined;
749
+ return {
750
+ [EXPECTED_CH_SCHEMA_VERSION_ENV]: version
751
+ };
752
+ }
673
753
  materialiseScheduledTasks(id, props) {
674
754
  const scheduledTasks = props.cluster?.scheduledTasks;
675
755
  if (!scheduledTasks || scheduledTasks.length === 0)
@@ -23,7 +23,7 @@ import { type Secret } from "../../../resources/aws/secrets/index.js";
23
23
  import { type SnapshotTarget } from "../../../utils/databaseTypes.js";
24
24
  import { type IMigrationContributor } from "./migrationContributor.js";
25
25
  export { type SnapshotTarget } from "../../../utils/databaseTypes.js";
26
- export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV } from "@fjall/util";
26
+ export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV } from "@fjall/util";
27
27
  /**
28
28
  * Declarative migration configuration on a relational database. Drives:
29
29
  * - the schema-version resolver at synth (filesystem scan of `dir`)
@@ -42,6 +42,21 @@ export type MigrationsConfig = {
42
42
  readonly dir: string;
43
43
  readonly versionResolver: (dir: string) => string;
44
44
  };
45
+ /**
46
+ * Declarative migration configuration on a ClickHouse database. Drives:
47
+ * - the schema-version resolver at synth (filesystem scan of `dir`)
48
+ * - the `EXPECTED_CH_SCHEMA_VERSION` auto-injection through every
49
+ * connected service's containers
50
+ *
51
+ * The default resolver picks the lexicographically-latest `*.sql` filename
52
+ * under `dir` (skipping `*.dev.sql`), matching what `runSqlMigrations`
53
+ * records in `_schema_migrations.ch_version`. `versionResolver` overrides
54
+ * the default for projects with a different file convention.
55
+ */
56
+ export type ClickHouseMigrationsConfig = {
57
+ readonly dir: string;
58
+ readonly versionResolver?: (dir: string) => string;
59
+ };
45
60
  /**
46
61
  * Database type discriminator.
47
62
  * Relational types share the IRelationalDatabase interface.
@@ -267,6 +282,22 @@ export interface IClickHouseDatabase extends IDatabase, IConnectable, IMigration
267
282
  getBackupBucket(): IBucket;
268
283
  /** Get the cold-tier bucket, or `undefined` when cold-tier storage is disabled. */
269
284
  getColdTierBucket(): IBucket | undefined;
285
+ /**
286
+ * Returns the migration configuration declared on this ClickHouse database,
287
+ * or `undefined` when the user did not declare one. When set, every
288
+ * container of every service in this database's `connections:` graph
289
+ * receives an `EXPECTED_CH_SCHEMA_VERSION` env entry (unless service-level
290
+ * `schemaGate: false`).
291
+ */
292
+ getMigrationsConfig(): ClickHouseMigrationsConfig | undefined;
293
+ /**
294
+ * Resolves `migrations.dir` via the resolver (default
295
+ * `pickLatestClickHouseMigration`) at synth time. Returns `undefined`
296
+ * when no `migrations:` was declared. Throws when declared but the
297
+ * directory is empty / unreadable / contains no matching entries — fail
298
+ * loud, never silent.
299
+ */
300
+ getExpectedSchemaVersion(): string | undefined;
270
301
  /**
271
302
  * Grant connect permissions to a grantee.
272
303
  * Adds the grantee to the database security group on both ports.
@@ -13,7 +13,7 @@
13
13
  * dynamo.getTableName(); // ✓ Available on IDynamoDBDatabase
14
14
  * dynamo.getHostEndpoint(); // ✗ Compile error - not on IDynamoDBDatabase
15
15
  */
16
- export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV } from "@fjall/util";
16
+ export { MIGRATION_SNAPSHOT_NAME_PREFIX, EXPECTED_SCHEMA_VERSION_ENV, EXPECTED_SCHEMA_VERSION_TOOL_ENV, EXPECTED_CH_SCHEMA_VERSION_ENV } from "@fjall/util";
17
17
  /**
18
18
  * Relational database type discriminator.
19
19
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/components-infrastructure",
3
- "version": "0.100.0",
3
+ "version": "0.102.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,8 +61,8 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@aws-sdk/client-organizations": "^3.1038.0",
64
- "@fjall/generator": "^0.100.0",
65
- "@fjall/util": "^0.100.0",
64
+ "@fjall/generator": "^0.102.0",
65
+ "@fjall/util": "^0.102.0",
66
66
  "constructs": "^10.0.0",
67
67
  "uuid": "^14.0.0"
68
68
  },
@@ -77,5 +77,5 @@
77
77
  "engines": {
78
78
  "node": ">=18.0.0"
79
79
  },
80
- "gitHead": "9eb16c56de49e6757cfd6352ad860dd125c6c21e"
80
+ "gitHead": "04a4f13181c261cc063786eae527fa82c90a610e"
81
81
  }