@geekmidas/cli 10.0.0-alpha.39 → 10.0.0-alpha.40

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 (47) hide show
  1. package/dist/{OpenApiTsGenerator-CeEI4ll9.cjs → OpenApiTsGenerator-B4Kj_qNU.cjs} +12 -5
  2. package/dist/{OpenApiTsGenerator-CeEI4ll9.cjs.map → OpenApiTsGenerator-B4Kj_qNU.cjs.map} +1 -1
  3. package/dist/{OpenApiTsGenerator-DQqOuSW8.mjs → OpenApiTsGenerator-CEh3EKN3.mjs} +12 -5
  4. package/dist/{OpenApiTsGenerator-DQqOuSW8.mjs.map → OpenApiTsGenerator-CEh3EKN3.mjs.map} +1 -1
  5. package/dist/config.d.cts +2 -2
  6. package/dist/config.d.mts +2 -2
  7. package/dist/discover-B0f_kuNN.cjs.map +1 -1
  8. package/dist/discover-BLhf7V_J.mjs.map +1 -1
  9. package/dist/{index-CLH1DgsO.d.cts → index-B3PeGOQp.d.cts} +2 -2
  10. package/dist/{index-CLH1DgsO.d.cts.map → index-B3PeGOQp.d.cts.map} +1 -1
  11. package/dist/{index-CST2jOPH.d.mts → index-BOyaGcCm.d.mts} +2 -2
  12. package/dist/{index-CST2jOPH.d.mts.map → index-BOyaGcCm.d.mts.map} +1 -1
  13. package/dist/index.cjs +206 -172
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.mjs +206 -172
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/{migrate-BJR912RE.mjs → migrate-BVI3yqcE.mjs} +2 -2
  18. package/dist/{migrate-BJR912RE.mjs.map → migrate-BVI3yqcE.mjs.map} +1 -1
  19. package/dist/{migrate-Bu8POqsR.cjs → migrate-CFOUApUt.cjs} +2 -2
  20. package/dist/{migrate-Bu8POqsR.cjs.map → migrate-CFOUApUt.cjs.map} +1 -1
  21. package/dist/openapi.cjs +1 -1
  22. package/dist/openapi.d.cts +1 -1
  23. package/dist/openapi.d.mts +1 -1
  24. package/dist/openapi.mjs +1 -1
  25. package/dist/{ready-BXmLS4VB.mjs → ready-D8UXsP6q.mjs} +2 -2
  26. package/dist/{ready-BXmLS4VB.mjs.map → ready-D8UXsP6q.mjs.map} +1 -1
  27. package/dist/{ready-B0WVtkjR.cjs → ready-mcxamf73.cjs} +2 -2
  28. package/dist/{ready-B0WVtkjR.cjs.map → ready-mcxamf73.cjs.map} +1 -1
  29. package/dist/reconcile/public.d.cts +1 -1
  30. package/dist/reconcile/public.d.mts +1 -1
  31. package/dist/{types-B0EVAF6U.d.mts → types-0R124hrY.d.mts} +1 -2
  32. package/dist/types-0R124hrY.d.mts.map +1 -0
  33. package/dist/{types-CHKCUKM0.d.cts → types-DfdYe31u.d.cts} +1 -2
  34. package/dist/types-DfdYe31u.d.cts.map +1 -0
  35. package/dist/vitest.cjs +1 -1
  36. package/dist/vitest.mjs +1 -1
  37. package/dist/workspace/index.d.cts +2 -2
  38. package/dist/workspace/index.d.mts +2 -2
  39. package/dist/{workspace-DZ1G4zow.cjs → workspace-BIqeeqcR.cjs} +167 -33
  40. package/dist/workspace-BIqeeqcR.cjs.map +1 -0
  41. package/dist/{workspace-CrFbItPz.mjs → workspace-DwS1JK5u.mjs} +165 -33
  42. package/dist/workspace-DwS1JK5u.mjs.map +1 -0
  43. package/package.json +16 -14
  44. package/dist/types-B0EVAF6U.d.mts.map +0 -1
  45. package/dist/types-CHKCUKM0.d.cts.map +0 -1
  46. package/dist/workspace-CrFbItPz.mjs.map +0 -1
  47. package/dist/workspace-DZ1G4zow.cjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -5,10 +5,10 @@ import { d as parseModuleConfig, f as appKey, l as loadWorkspaceConfig, n as Not
5
5
  import { D as assertDeployedStage, E as TEST_STAGE, O as deployedProblems, T as InvalidStages, a as getAppBuildOrder, b as getDeployTargetError, k as stageProblems, m as stripPublicPrefix, o as getAppGkmConfig, p as getPublicEnvPrefix, t as allConstructGlobs, x as isDeployTargetSupported } from "./workspace-DOmIhiau.mjs";
6
6
  import { a as withCustomSecret, i as toEmbeddableSecrets, n as maskPassword, t as initStageSecrets } from "./storage-BzaUxmqe.mjs";
7
7
  import { a as getKeyPath, i as FileSecretsStore, n as secretsStoreFor } from "./store-DeGATCS-.mjs";
8
- import { C as findAvailablePort, D as prepareEntryCredentials, E as loadSecretsForApp, S as createEntryWrapper, T as loadEnvFiles, _ as cacheBackendFor, a as reconcileWorkspace, b as storageBackendFor, c as LOCAL_CA_PATH, d as quoteIdentifier, f as pgClient, g as primaryPortKey, h as fakeKey, i as derivedContainers, l as forgetState, m as rootSite, n as backendsOf, p as resourceName, r as constructGlobs, s as COMPOSE_PATH, t as FAKE_ENV, u as applyPostgres, v as eventsBackendFor, w as isPortAvailable, x as createCredentialsPreload, y as providerOf } from "./workspace-CrFbItPz.mjs";
9
- import { n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-DQqOuSW8.mjs";
8
+ import { C as findAvailablePort, D as prepareEntryCredentials, E as loadSecretsForApp, S as createEntryWrapper, T as loadEnvFiles, _ as cacheBackendFor, a as reconcileWorkspace, b as storageBackendFor, c as LOCAL_CA_PATH, d as quoteIdentifier, f as pgClient, g as primaryPortKey, h as fakeKey, i as derivedContainers, l as forgetState, m as rootSite, n as backendsOf, p as resourceName, r as constructGlobs, s as COMPOSE_PATH, t as FAKE_ENV, u as applyPostgres, v as eventsBackendFor, w as isPortAvailable, x as createCredentialsPreload, y as providerOf } from "./workspace-DwS1JK5u.mjs";
9
+ import { n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-CEh3EKN3.mjs";
10
10
  import { generateOpenApi, openapiCommand, resolveOpenApiConfig } from "./openapi.mjs";
11
- import { a as writeTestHarness, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as cacheBackendsIn, s as driversFor, t as TEST_READY_ENV } from "./ready-BXmLS4VB.mjs";
11
+ import { a as writeTestHarness, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as cacheBackendsIn, s as driversFor, t as TEST_READY_ENV } from "./ready-D8UXsP6q.mjs";
12
12
  import { i as withRoutes, r as withCompute } from "./emit-Bzq7JsMP.mjs";
13
13
  import { createRequire } from "node:module";
14
14
  import { Command } from "commander";
@@ -41,7 +41,7 @@ import { Subscriber } from "@geekmidas/constructs/subscribers";
41
41
  import { Topic } from "@geekmidas/constructs/topic";
42
42
  import { lookup } from "node:dns/promises";
43
43
  //#region package.json
44
- var version = "10.0.0-alpha.39";
44
+ var version = "10.0.0-alpha.40";
45
45
  //#endregion
46
46
  //#region src/auth/credentials.ts
47
47
  /**
@@ -1208,7 +1208,10 @@ function getProductionConfigFromGkm(config) {
1208
1208
  const serverConfig = config.providers?.server;
1209
1209
  if (typeof serverConfig === "object") return serverConfig.production;
1210
1210
  }
1211
+ /** Read by the generated server: no subscriber is subscribed or polled. */
1212
+ const SUBSCRIBERS_ENV = "GKM_SUBSCRIBERS";
1211
1213
  async function devCommand(options) {
1214
+ if (options.subscribers === false) process.env[SUBSCRIBERS_ENV] = "off";
1212
1215
  if (options.entry) return entryDevCommand(options);
1213
1216
  const defaultEnv = loadEnvFiles(".env");
1214
1217
  if (defaultEnv.loaded.length > 0) logger$10.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
@@ -1563,9 +1566,9 @@ async function workspaceDevCommand(workspace, options) {
1563
1566
  const fakes = options.fake ? await serveFakes(reconciled.fakes, reconciled.ports) : [];
1564
1567
  for (const { id, port } of fakes) logger$10.log(`🎭 ${id} fake: http://localhost:${port}`);
1565
1568
  if (options.migrate || options.seed) {
1566
- const { prepareDevDatabases } = await import("./migrate-BJR912RE.mjs");
1569
+ const { prepareDevDatabases } = await import("./migrate-BVI3yqcE.mjs");
1567
1570
  await prepareDevDatabases(workspace, reconciled.env, { seed: !!options.seed });
1568
- } else await import("./migrate-BJR912RE.mjs").then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
1571
+ } else await import("./migrate-BVI3yqcE.mjs").then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
1569
1572
  logger$10.log(`⚠️ Could not check for pending migrations: ${error instanceof Error ? error.message : String(error)}`);
1570
1573
  });
1571
1574
  const appUrls = {};
@@ -2210,7 +2213,7 @@ export async function setupCrons(
2210
2213
  // runtime role, which can neither create a schema nor use the broker's.
2211
2214
  const { url } = envParser
2212
2215
  .create((get) => ({
2213
- url: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string().optional(),
2216
+ url: get('EVENT_PUBLISHER_CONNECTION_STRING').string().optional(),
2214
2217
  }))
2215
2218
  .parse();
2216
2219
 
@@ -2219,7 +2222,7 @@ export async function setupCrons(
2219
2222
  { crons: prepared.map(({ name }) => name) },
2220
2223
  'These crons have nowhere to keep their schedule. On a server they are ' +
2221
2224
  'scheduled through the pg-boss events broker ' +
2222
- '(EVENT_SUBSCRIBER_CONNECTION_STRING), and ' +
2225
+ '(EVENT_PUBLISHER_CONNECTION_STRING), and ' +
2223
2226
  (url ? 'the broker configured is not pg-boss.' : 'none is configured.'),
2224
2227
  );
2225
2228
  return;
@@ -2332,11 +2335,11 @@ export const handler = adapter.handler;
2332
2335
  //#endregion
2333
2336
  //#region src/generators/QueueGenerator.ts
2334
2337
  /**
2335
- * Generates the runtime for `q` queue workers.
2338
+ * Generates the runtime for queue consumers (`worker.queue(…)`).
2336
2339
  *
2337
2340
  * - **server** (`gkm dev`): a single `queues.ts` exposing `setupQueues()` that
2338
- * runs an in-process pg-boss poller alongside the Hono server — no SQS/Lambda.
2339
- * Each queue subscribes by its **name** on the shared event connection.
2341
+ * polls each queue in-process, alongside the Hono server, through the queue's
2342
+ * own connection string — pg-boss by default, SQS on the AWS emulator.
2340
2343
  * - **aws-lambda**: one handler file per queue wrapping `AWSLambdaQueue`, backed
2341
2344
  * by an SQS event-source mapping.
2342
2345
  */
@@ -2411,64 +2414,52 @@ export async function setupQueues(
2411
2414
  importsByFile.get(importPath)?.push(key);
2412
2415
  }
2413
2416
  const content = `/**
2414
- * Generated queue workers setup
2417
+ * Generated queue consumers setup.
2415
2418
  *
2416
- * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
2417
- * This runs an in-process pg-boss poller alongside the HTTP server. Each queue
2418
- * subscribes by its name on the shared event connection
2419
- * (EVENT_SUBSCRIBER_CONNECTION_STRING), so producers using
2420
- * <NAME>_PUBLISHER_CONNECTION_STRING (pgboss:// locally) reach it.
2419
+ * Each queue's one consumer polls its own queue, through the queue's own
2420
+ * connection string — the one its producers publish on. SQS cannot push, so
2421
+ * this is a poller on every transport: pg-boss locally by default, the SQS
2422
+ * queue on the AWS emulator.
2421
2423
  *
2422
- * For production, queues run as AWS Lambda with SQS event-source mappings.
2424
+ * Deployed on Lambda, a queue's consumer is an SQS event source instead.
2423
2425
  */
2424
2426
  import type { EnvironmentParser } from '@geekmidas/envkit';
2425
2427
  import type { Logger } from '@geekmidas/logger';
2426
2428
  import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
2427
- import type { EventConnection, EventSubscriber } from '@geekmidas/events';
2429
+ import type { EventConnection } from '@geekmidas/events';
2428
2430
  import { ServiceDiscovery } from '@geekmidas/services';
2429
2431
  ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
2430
2432
 
2431
2433
  const queues = [
2432
- ${queues.map(({ key }) => key).join(",\n ")}
2434
+ ${queues.map(({ key, construct }) => ` { queue: ${key}, connectionKey: '${provideKey(construct.id, "publisherConnectionString")}' },`).join("\n")}
2433
2435
  ];
2434
2436
 
2435
- const activeSubscribers: EventSubscriber<any>[] = [];
2436
-
2437
2437
  export async function setupQueues(
2438
2438
  envParser: EnvironmentParser<any>,
2439
2439
  logger: Logger,
2440
2440
  ): Promise<void> {
2441
- if (queues.length === 0) {
2442
- return;
2443
- }
2444
-
2445
- logger.info('Setting up queue workers in polling mode (local development)');
2446
-
2447
- const config = envParser.create((get) => ({
2448
- connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
2449
- })).parse();
2450
-
2451
2441
  const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
2442
+ const connections = new Map<string, EventConnection>();
2452
2443
 
2453
- let connection: EventConnection;
2454
- try {
2455
- connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
2456
- const connectionType = new URL(config.connectionString).protocol.replace(':', '');
2457
- logger.info({ connectionType }, 'Created shared event connection for queues');
2458
- } catch (error) {
2459
- logger.error({ error }, 'Failed to create event connection for queues');
2460
- return;
2461
- }
2444
+ for (const { queue, connectionKey } of queues) {
2445
+ const connectionString = process.env[connectionKey];
2446
+ if (!connectionString) {
2447
+ logger.error({ queue: queue.name, connectionKey }, 'No connection string for this queue');
2448
+ continue;
2449
+ }
2462
2450
 
2463
- for (const queue of queues) {
2464
2451
  try {
2452
+ let connection = connections.get(connectionString);
2453
+ if (!connection) {
2454
+ connection = await EventConnectionFactory.fromConnectionString(connectionString);
2455
+ connections.set(connectionString, connection);
2456
+ }
2465
2457
  const eventSubscriber = await Subscriber.fromConnection(connection);
2466
-
2467
2458
  const services = queue.services.length > 0
2468
2459
  ? await serviceDiscovery.register(queue.services)
2469
2460
  : {};
2470
2461
 
2471
- // A queue subscribes to a single "type" — its own name.
2462
+ // A queue's messages carry one type — its own name.
2472
2463
  await eventSubscriber.subscribe([queue.name], async (message) => {
2473
2464
  try {
2474
2465
  const validation = await queue.messageSchema['~standard'].validate(message.payload);
@@ -2482,30 +2473,23 @@ export async function setupQueues(
2482
2473
  services: services,
2483
2474
  logger: queue.logger,
2484
2475
  });
2485
-
2486
- logger.debug({ queue: queue.name }, 'Successfully processed queue message');
2487
2476
  } catch (error) {
2488
2477
  logger.error({ error, queue: queue.name }, 'Failed to process queue message');
2489
- // Message will become visible again for retry.
2478
+ // Rethrown so the transport keeps the message for a retry.
2479
+ throw error;
2490
2480
  }
2491
2481
  });
2492
-
2493
- activeSubscribers.push(eventSubscriber);
2494
- logger.info({ queue: queue.name }, 'Queue worker started polling');
2482
+ logger.info({ queue: queue.name }, 'Queue consumer started polling');
2495
2483
  } catch (error) {
2496
- logger.error({ error, queue: queue.name }, 'Failed to setup queue worker');
2484
+ logger.error({ error, queue: queue.name }, 'Failed to set up queue consumer');
2497
2485
  }
2498
2486
  }
2499
2487
 
2500
2488
  const shutdown = () => {
2501
- logger.info('Stopping all queue workers');
2502
- for (const _ of activeSubscribers) {
2503
- connection.stop();
2504
- }
2489
+ for (const connection of connections.values()) void connection.close();
2505
2490
  };
2506
-
2507
- process.on('SIGTERM', shutdown);
2508
- process.on('SIGINT', shutdown);
2491
+ process.once('SIGTERM', shutdown);
2492
+ process.once('SIGINT', shutdown);
2509
2493
  }
2510
2494
  `;
2511
2495
  await writeFile(queuesPath, content);
@@ -2578,141 +2562,194 @@ import type { EnvironmentParser } from '@geekmidas/envkit';
2578
2562
  import type { Logger } from '@geekmidas/logger';
2579
2563
 
2580
2564
  export async function setupSubscribers(
2565
+ _app: unknown,
2581
2566
  _envParser: EnvironmentParser<any>,
2582
2567
  _logger: Logger,
2583
- ): Promise<void> {}
2568
+ ): Promise<(port: number) => Promise<void>> {
2569
+ return async () => {};
2570
+ }
2584
2571
  `);
2585
2572
  return subscribersPath;
2586
2573
  }
2587
2574
  const importsByFile = /* @__PURE__ */ new Map();
2588
2575
  for (const { path, key } of subscribers) {
2589
2576
  const importPath = relative(dirname(subscribersPath), path.relative).replace(/\.ts$/, ".js");
2590
- if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
2591
- importsByFile.get(importPath)?.push(key);
2577
+ importsByFile.set(importPath, [...importsByFile.get(importPath) ?? [], key]);
2592
2578
  }
2593
2579
  const content = `/**
2594
- * Generated subscribers setup
2580
+ * Generated subscribers setup.
2595
2581
  *
2596
- * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
2597
- * This uses event polling which is not suitable for production.
2582
+ * Each subscriber is run against the topic it is bound to, through that
2583
+ * topic's own connection string:
2598
2584
  *
2599
- * For production, use AWS Lambda with SQS/SNS event source mappings.
2600
- * Lambda automatically:
2601
- * - Scales based on queue depth
2602
- * - Handles batch processing and retries
2603
- * - Manages dead letter queues
2604
- * - Provides better cost optimization
2585
+ * - **sns://** — pushed, not polled. The subscriber gets an HTTP route on this
2586
+ * server, and the route is subscribed to the topic with a filter policy of
2587
+ * the events it named, so SNS fans out to each subscriber. The route runs the
2588
+ * subscriber through the same adaptor a Lambda subscription does.
2589
+ * - **anything else** (pgboss://, rabbitmq://, sqs://) — polled. On pg-boss
2590
+ * each subscriber drains a queue of its own, \`<topic>/<subscriber>\`, so
2591
+ * every subscriber sees every event; replicas of one subscriber share it.
2605
2592
  *
2606
- * This polling implementation is useful for:
2607
- * - Local development and testing
2608
- * - Understanding event flow without Lambda deployment
2609
- *
2610
- * Supported connection strings:
2611
- * - sqs://region/account-id/queue-name (SQS queue)
2612
- * - sns://region/account-id/topic-name (SNS topic)
2613
- * - rabbitmq://host:port/queue-name (RabbitMQ)
2614
- * - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
2615
- * - basic://in-memory (In-memory for testing)
2593
+ * GKM_SUBSCRIBERS=off (\`gkm dev --no-subscribers\`) runs none of them.
2594
+ * GKM_SUBSCRIBER_PUSH_URL is the address SNS pushes to; against the local
2595
+ * emulator it defaults to this server through host.docker.internal.
2616
2596
  */
2597
+ import { connect } from 'node:net';
2617
2598
  import type { EnvironmentParser } from '@geekmidas/envkit';
2618
2599
  import type { Logger } from '@geekmidas/logger';
2619
2600
  import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
2620
- import type { EventConnection, EventSubscriber } from '@geekmidas/events';
2601
+ import type { EventConnection } from '@geekmidas/events';
2621
2602
  import { ServiceDiscovery } from '@geekmidas/services';
2622
2603
  ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
2623
2604
 
2624
2605
  const subscribers = [
2625
- ${subscribers.map(({ key }) => key).join(",\n ")}
2606
+ ${subscribers.map(({ key, construct }) => {
2607
+ const connectionKey = construct.topicName ? provideKey(canonicalId(construct.topicName), "publisherConnectionString") : void 0;
2608
+ return ` { id: '${key}', subscriber: ${key}, topic: ${construct.topicName ? `'${construct.topicName}'` : "undefined"}, connectionKey: ${connectionKey ? `'${connectionKey}'` : "undefined"} },`;
2609
+ }).join("\n")}
2626
2610
  ];
2627
2611
 
2628
- const activeSubscribers: EventSubscriber<any>[] = [];
2612
+ /** The route a subscriber's pushes arrive on. */
2613
+ export const SUBSCRIBER_ROUTE = (id: string) => \`/__gkm/subscribers/\${id}\`;
2629
2614
 
2615
+ /** Resolves once something is accepting connections on the port. */
2616
+ async function listening(port: number): Promise<void> {
2617
+ for (let attempt = 0; attempt < 100; attempt++) {
2618
+ const open = await new Promise<boolean>((resolve) => {
2619
+ const socket = connect(port, '127.0.0.1');
2620
+ socket.once('connect', () => { socket.end(); resolve(true); });
2621
+ socket.once('error', () => resolve(false));
2622
+ });
2623
+ if (open) return;
2624
+ await new Promise((resolve) => setTimeout(resolve, 50));
2625
+ }
2626
+ }
2627
+
2628
+ /**
2629
+ * Mount each pushed subscriber's route and start each polled one. Returns what
2630
+ * to run once the server is listening: subscribing a route makes SNS send its
2631
+ * confirmation at once, so the route has to be reachable first.
2632
+ */
2630
2633
  export async function setupSubscribers(
2634
+ app: { post: (path: string, handler: (c: any) => unknown) => unknown },
2631
2635
  envParser: EnvironmentParser<any>,
2632
2636
  logger: Logger,
2633
- ): Promise<void> {
2634
- if (subscribers.length === 0) {
2635
- return;
2637
+ ): Promise<(port: number) => Promise<void>> {
2638
+ if (process.env.GKM_SUBSCRIBERS === 'off') {
2639
+ logger.info('Subscribers are off (--no-subscribers)');
2640
+ return async () => {};
2636
2641
  }
2637
2642
 
2638
- logger.info('Setting up subscribers in polling mode (local development)');
2639
-
2640
- const config = envParser.create((get) => ({
2641
- connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
2642
- })).parse();
2643
-
2644
2643
  const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
2644
+ const connections = new Map<string, EventConnection>();
2645
+ const afterListening: ((port: number) => Promise<void>)[] = [];
2646
+
2647
+ for (const { id, subscriber, topic, connectionKey } of subscribers) {
2648
+ const events = subscriber.subscribedEvents || [];
2649
+ if (events.length === 0) {
2650
+ logger.warn({ subscriber: id }, 'Subscriber has no subscribed events, skipping');
2651
+ continue;
2652
+ }
2653
+ const connectionString = connectionKey ? process.env[connectionKey] : undefined;
2654
+ if (!connectionString) {
2655
+ logger.error(
2656
+ { subscriber: id, connectionKey },
2657
+ 'No connection string for the topic this subscriber is bound to',
2658
+ );
2659
+ continue;
2660
+ }
2645
2661
 
2646
- // Create connection once, outside the loop (more efficient)
2647
- // EventConnectionFactory automatically determines the right connection type
2648
- let connection: EventConnection;
2649
- try {
2650
- connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
2651
-
2652
- const connectionType = new URL(config.connectionString).protocol.replace(':', '');
2653
- logger.info({ connectionType }, 'Created shared event connection');
2654
- } catch (error) {
2655
- logger.error({ error }, 'Failed to create event connection');
2656
- return;
2657
- }
2658
-
2659
- for (const subscriber of subscribers) {
2660
2662
  try {
2661
- // Create subscriber from shared connection
2662
- const eventSubscriber = await Subscriber.fromConnection(connection);
2663
+ if (connectionString.startsWith('sns:')) {
2664
+ const { SNSConnection, snsUrl, subscribeHttpEndpoint } = await import('@geekmidas/events/sns');
2665
+ const { SnsPushSubscriberAdaptor } = await import('@geekmidas/constructs/aws');
2666
+ const address = snsUrl.parse(connectionString);
2667
+ const route = SUBSCRIBER_ROUTE(id);
2668
+ const adaptor = new SnsPushSubscriberAdaptor(envParser, subscriber, {
2669
+ topicArn: address.topicArn,
2670
+ // An emulator signs nothing. Decided by the address this server was
2671
+ // configured with, never by anything a request says about itself.
2672
+ verify: !address.endpoint,
2673
+ ...(address.endpoint ? { emulatorEndpoint: address.endpoint } : {}),
2674
+ });
2675
+
2676
+ app.post(route, async (c: any) => {
2677
+ const body = JSON.parse(await c.req.text());
2678
+ const response = await adaptor.handle(body);
2679
+ return c.json(response.body, response.status);
2680
+ });
2681
+
2682
+ afterListening.push(async (port) => {
2683
+ const base =
2684
+ process.env.GKM_SUBSCRIBER_PUSH_URL ??
2685
+ (address.endpoint ? \`http://host.docker.internal:\${port}\` : undefined);
2686
+ if (!base) {
2687
+ logger.error(
2688
+ { subscriber: id },
2689
+ 'Set GKM_SUBSCRIBER_PUSH_URL to the public URL of this worker, which SNS pushes to',
2690
+ );
2691
+ return;
2692
+ }
2693
+ const connection = await SNSConnection.fromConnectionString(connectionString);
2694
+ await subscribeHttpEndpoint(connection, {
2695
+ endpoint: new URL(route, base).toString(),
2696
+ events,
2697
+ });
2698
+ logger.info({ subscriber: id, events }, 'Subscriber subscribed for push');
2699
+ });
2700
+ continue;
2701
+ }
2663
2702
 
2664
- // Register services
2703
+ let connection = connections.get(connectionString);
2704
+ if (!connection) {
2705
+ connection = await EventConnectionFactory.fromConnectionString(connectionString);
2706
+ connections.set(connectionString, connection);
2707
+ }
2708
+ // Named, so a broker with one address for everything (pg-boss) gives
2709
+ // this subscriber a queue of its own: fan-out, not competition.
2710
+ const eventSubscriber = await Subscriber.fromConnection(connection, {
2711
+ topic,
2712
+ subscription: id,
2713
+ });
2665
2714
  const services = subscriber.services.length > 0
2666
2715
  ? await serviceDiscovery.register(subscriber.services)
2667
2716
  : {};
2668
2717
 
2669
- // Subscribe to events
2670
- const subscribedEvents = subscriber.subscribedEvents || [];
2671
-
2672
- if (subscribedEvents.length === 0) {
2673
- logger.warn({ subscriber: subscriber.constructor.name }, 'Subscriber has no subscribed events, skipping');
2674
- continue;
2675
- }
2676
-
2677
- await eventSubscriber.subscribe(subscribedEvents, async (event) => {
2718
+ await eventSubscriber.subscribe(events, async (event) => {
2678
2719
  try {
2679
- // Process single event (batch of 1)
2680
2720
  await subscriber.handler({
2681
2721
  events: [event],
2682
2722
  services: services as any,
2683
2723
  logger: subscriber.logger,
2684
2724
  });
2685
-
2686
- logger.debug({ eventType: event.type }, 'Successfully processed event');
2687
2725
  } catch (error) {
2688
- logger.error({ error, event }, 'Failed to process event');
2689
- // Event will become visible again for retry
2726
+ logger.error({ error, event, subscriber: id }, 'Failed to process event');
2727
+ // Rethrown so the transport keeps the event for a retry — as a queue
2728
+ // consumer's failure does, and as a pushed subscriber's 500 does.
2729
+ throw error;
2690
2730
  }
2691
2731
  });
2692
-
2693
- activeSubscribers.push(eventSubscriber);
2694
-
2695
- logger.info(
2696
- {
2697
- events: subscribedEvents,
2698
- },
2699
- 'Subscriber started polling'
2700
- );
2732
+ logger.info({ subscriber: id, events }, 'Subscriber started polling');
2701
2733
  } catch (error) {
2702
- logger.error({ error, subscriber: subscriber.constructor.name }, 'Failed to setup subscriber');
2734
+ logger.error({ error, subscriber: id }, 'Failed to set up subscriber');
2703
2735
  }
2704
2736
  }
2705
2737
 
2706
- // Setup graceful shutdown
2707
2738
  const shutdown = () => {
2708
- logger.info('Stopping all subscribers');
2709
- for (const eventSubscriber of activeSubscribers) {
2710
- connection.stop();
2739
+ for (const connection of connections.values()) void connection.close();
2740
+ };
2741
+ process.once('SIGTERM', shutdown);
2742
+ process.once('SIGINT', shutdown);
2743
+
2744
+ return async (port) => {
2745
+ if (afterListening.length === 0) return;
2746
+ await listening(port);
2747
+ for (const subscribe of afterListening) {
2748
+ await subscribe(port).catch((error) => {
2749
+ logger.error({ error }, 'Failed to subscribe for push');
2750
+ });
2711
2751
  }
2712
2752
  };
2713
-
2714
- process.on('SIGTERM', shutdown);
2715
- process.on('SIGINT', shutdown);
2716
2753
  }
2717
2754
  `;
2718
2755
  await writeFile(subscribersPath, content);
@@ -8642,11 +8679,7 @@ function generateConnectionUrls(services) {
8642
8679
  if (services.postgres) urls.DATABASE_URL = generatePostgresUrl(services.postgres);
8643
8680
  if (services.redis) urls.REDIS_URL = generateRedisUrl(services.redis);
8644
8681
  if (services.minio) urls.STORAGE_ENDPOINT = generateMinioEndpoint(services.minio);
8645
- if (services.pgboss) {
8646
- const pgbossUrl = generatePgBossUrl(services.pgboss);
8647
- urls.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
8648
- urls.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
8649
- }
8682
+ if (services.pgboss) urls.EVENT_PUBLISHER_CONNECTION_STRING = generatePgBossUrl(services.pgboss);
8650
8683
  if (services.mailpit) {
8651
8684
  urls.SMTP_HOST = services.mailpit.host;
8652
8685
  urls.SMTP_PORT = String(services.mailpit.port);
@@ -9543,26 +9576,26 @@ function loadPackageJson() {
9543
9576
  * Run: pnpm --filter @geekmidas/cli sync-versions
9544
9577
  */
9545
9578
  const GEEKMIDAS_VERSIONS = {
9546
- "@geekmidas/audit": "~10.0.0-alpha.39",
9547
- "@geekmidas/auth": "~10.0.0-alpha.39",
9548
- "@geekmidas/cache": "~10.0.0-alpha.39",
9549
- "@geekmidas/client": "~10.0.0-alpha.39",
9550
- "@geekmidas/cloud": "~10.0.0-alpha.39",
9551
- "@geekmidas/constructs": "~10.0.0-alpha.39",
9552
- "@geekmidas/db": "~10.0.0-alpha.39",
9553
- "@geekmidas/emailkit": "~10.0.0-alpha.39",
9554
- "@geekmidas/envkit": "~10.0.0-alpha.39",
9555
- "@geekmidas/errors": "~10.0.0-alpha.39",
9556
- "@geekmidas/events": "~10.0.0-alpha.39",
9557
- "@geekmidas/logger": "~10.0.0-alpha.39",
9558
- "@geekmidas/manifest": "~10.0.0-alpha.39",
9559
- "@geekmidas/rate-limit": "~10.0.0-alpha.39",
9560
- "@geekmidas/schema": "~10.0.0-alpha.39",
9561
- "@geekmidas/services": "~10.0.0-alpha.39",
9562
- "@geekmidas/storage": "~10.0.0-alpha.39",
9563
- "@geekmidas/studio": "~10.0.0-alpha.39",
9564
- "@geekmidas/telescope": "~10.0.0-alpha.39",
9565
- "@geekmidas/testkit": "~10.0.0-alpha.39",
9579
+ "@geekmidas/audit": "~10.0.0-alpha.40",
9580
+ "@geekmidas/auth": "~10.0.0-alpha.40",
9581
+ "@geekmidas/cache": "~10.0.0-alpha.40",
9582
+ "@geekmidas/client": "~10.0.0-alpha.40",
9583
+ "@geekmidas/cloud": "~10.0.0-alpha.40",
9584
+ "@geekmidas/constructs": "~10.0.0-alpha.40",
9585
+ "@geekmidas/db": "~10.0.0-alpha.40",
9586
+ "@geekmidas/emailkit": "~10.0.0-alpha.40",
9587
+ "@geekmidas/envkit": "~10.0.0-alpha.40",
9588
+ "@geekmidas/errors": "~10.0.0-alpha.40",
9589
+ "@geekmidas/events": "~10.0.0-alpha.40",
9590
+ "@geekmidas/logger": "~10.0.0-alpha.40",
9591
+ "@geekmidas/manifest": "~10.0.0-alpha.40",
9592
+ "@geekmidas/rate-limit": "~10.0.0-alpha.40",
9593
+ "@geekmidas/schema": "~10.0.0-alpha.40",
9594
+ "@geekmidas/services": "~10.0.0-alpha.40",
9595
+ "@geekmidas/storage": "~10.0.0-alpha.40",
9596
+ "@geekmidas/studio": "~10.0.0-alpha.40",
9597
+ "@geekmidas/telescope": "~10.0.0-alpha.40",
9598
+ "@geekmidas/testkit": "~10.0.0-alpha.40",
9566
9599
  "@geekmidas/cli": `~${loadPackageJson().version}`
9567
9600
  };
9568
9601
  //#endregion
@@ -16311,7 +16344,7 @@ function testDatabases(reconciled) {
16311
16344
  * starts without the reference data the app needs.
16312
16345
  */
16313
16346
  async function migrateTestStage(workspace, env) {
16314
- const { migrateAndSeed } = await import("./migrate-BJR912RE.mjs");
16347
+ const { migrateAndSeed } = await import("./migrate-BVI3yqcE.mjs");
16315
16348
  const { discover } = await import("./discover-BLhf7V_J.mjs").then((n) => n.n);
16316
16349
  const sources = {};
16317
16350
  const manifest = await discover({
@@ -16711,7 +16744,7 @@ program.command("build").description("Build handlers and the manifest from decla
16711
16744
  process.exit(1);
16712
16745
  }
16713
16746
  });
16714
- program.command("dev").description("Reconcile constructs and start the dev server with automatic reload").option("-p, --port <port>", "Port to run the development server on").option("--entry <file>", "Entry file to run (bypasses gkm config)").option("--watch", "Watch for file changes (default: true with --entry)").option("--no-watch", "Disable file watching").option("--enable-openapi", "Enable OpenAPI documentation for development server", true).option("--migrate", "Apply pending migrations before the apps start").option("--seed", "Migrate, then run the seeds, before the apps start").option("--fake", "Call each external API’s fake (test/fakes/<id>.ts) instead of the provider").action(async (options) => {
16747
+ program.command("dev").description("Reconcile constructs and start the dev server with automatic reload").option("-p, --port <port>", "Port to run the development server on").option("--entry <file>", "Entry file to run (bypasses gkm config)").option("--watch", "Watch for file changes (default: true with --entry)").option("--no-watch", "Disable file watching").option("--enable-openapi", "Enable OpenAPI documentation for development server", true).option("--migrate", "Apply pending migrations before the apps start").option("--seed", "Migrate, then run the seeds, before the apps start").option("--fake", "Call each external API’s fake (test/fakes/<id>.ts) instead of the provider").option("--no-subscribers", "Run no topic subscribers: nothing is subscribed, polled or pushed to").action(async (options) => {
16715
16748
  try {
16716
16749
  const globalOptions = program.opts();
16717
16750
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -16723,7 +16756,8 @@ program.command("dev").description("Reconcile constructs and start the dev serve
16723
16756
  watch: options.watch,
16724
16757
  migrate: options.migrate,
16725
16758
  seed: options.seed,
16726
- fake: options.fake
16759
+ fake: options.fake,
16760
+ subscribers: options.subscribers
16727
16761
  });
16728
16762
  } catch (error) {
16729
16763
  console.error(formatError(error));
@@ -16757,7 +16791,7 @@ program.command("migrate").description("Apply each database construct's migratio
16757
16791
  try {
16758
16792
  const globalOptions = program.opts();
16759
16793
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16760
- const { migrateCommand } = await import("./migrate-BJR912RE.mjs");
16794
+ const { migrateCommand } = await import("./migrate-BVI3yqcE.mjs");
16761
16795
  await migrateCommand({
16762
16796
  ...options,
16763
16797
  ...construct ? { construct } : {}
@@ -16771,7 +16805,7 @@ program.command("seed").description("Migrate, then run each database construct's
16771
16805
  try {
16772
16806
  const globalOptions = program.opts();
16773
16807
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16774
- const { seedCommand } = await import("./migrate-BJR912RE.mjs");
16808
+ const { seedCommand } = await import("./migrate-BVI3yqcE.mjs");
16775
16809
  await seedCommand({
16776
16810
  ...options,
16777
16811
  ...construct ? { construct } : {}
@@ -16785,7 +16819,7 @@ program.command("migration").description("Write a construct's next migration: an
16785
16819
  try {
16786
16820
  const globalOptions = program.opts();
16787
16821
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16788
- const { migrationCommand } = await import("./migrate-BJR912RE.mjs");
16822
+ const { migrationCommand } = await import("./migrate-BVI3yqcE.mjs");
16789
16823
  await migrationCommand({
16790
16824
  construct,
16791
16825
  ...name ? { name } : {}
@@ -16924,7 +16958,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
16924
16958
  console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
16925
16959
  process.exit(1);
16926
16960
  }
16927
- const { derivedContainers } = await import("./workspace-CrFbItPz.mjs").then((n) => n.o);
16961
+ const { derivedContainers } = await import("./workspace-DwS1JK5u.mjs").then((n) => n.o);
16928
16962
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
16929
16963
  if (!result) {
16930
16964
  console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);