@ductape/mcp 0.1.38 → 0.1.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 (3) hide show
  1. package/dist/index.js +291 -69
  2. package/package.json +1 -1
  3. package/src/index.ts +291 -69
package/dist/index.js CHANGED
@@ -464,18 +464,18 @@ ALL params are passed as a JSON array in positional order matching the SDK signa
464
464
  messageBrokers.fetch [product_tag, broker_tag]
465
465
  messageBrokers.list [product_tag]
466
466
  messageBrokers.delete [product_tag, broker_tag]
467
- messageBrokers.topics.create [product_tag, data: { tag: string, name: string, broker: string,
468
- description?: string, sample?: object, idempotent?: boolean,
469
- queueUrls?: [{ env_slug: string, url: string }] // SQS only: per-env queue URL per topic
470
- }]
471
- OPTIONAL for most providers: creating a producer automatically creates the topic if it does not exist.
472
- Only required explicitly for SQS (must supply queueUrls per env) or when you want to set sample/idempotent upfront.
473
- For Pub/Sub, Kafka, RabbitMQ, Redis, NATS: skip this — let producer creation handle it.
474
- A broker can have unlimited topics. Add one per logical event type.
475
- messageBrokers.topics.update [product_tag, topic_tag, data: { name?: string, description?: string,
476
- sample?: object, idempotent?: boolean, queueUrls?: [{ env_slug: string, url: string }] }]
477
- messageBrokers.topics.fetch [product_tag, topic_tag]
478
- messageBrokers.topics.list [product_tag, broker_tag]
467
+ messageBrokers.topics.create FORBIDDEN with publishable key. Use ductape_cli instead:
468
+ ductape_cli("events topics create -f topic.json")
469
+ topic.json: { tag, name, broker, description?, sample?, idempotent?, queueUrls?: [{ env_slug, url }] }
470
+ ← Always required before consuming. For SQS: must include queueUrls per env.
471
+ For Pub/Sub, Kafka, RabbitMQ, Redis, NATS: the first produce call auto-registers the topic,
472
+ but you should still create it explicitly so consumers can subscribe before any produce occurs.
473
+ messageBrokers.topics.update FORBIDDEN with publishable key. Use ductape_cli:
474
+ ductape_cli("events topics update --tag broker:topic -f patch.json")
475
+ messageBrokers.topics.delete ← FORBIDDEN with publishable key. Use ductape_cli:
476
+ ductape_cli("events topics delete --tag broker:topic")
477
+ messageBrokers.topics.fetch [product_tag, topic_tag] ← safe via ductape_execute
478
+ messageBrokers.topics.list [product_tag, broker_tag] ← safe via ductape_execute
479
479
  messageBrokers.produce [{ product, env, event: "broker_tag:topic_tag", message: { key: value }, session?, cache? }]
480
480
  messageBrokers.consume [{ product, env, event: "broker_tag:topic_tag", callback: "function_ref" }]
481
481
  messageBrokers.dispatch [{ product, env, broker, event, input: { message }, retries?, session?, cache?, schedule?: { cron?, every?, start_at? } }]
@@ -1727,6 +1727,31 @@ Important:
1727
1727
  apps: `
1728
1728
  DUCTAPE APPS
1729
1729
 
1730
+ WHAT AN APP IS:
1731
+ A Ductape App is a pre-configured, versioned API integration definition. It is NOT a generic HTTP
1732
+ client, NOT a job scheduler, and NOT anything you can call without registering first.
1733
+
1734
+ An App must be fully set up in Ductape before any code can use it:
1735
+ 1. The App record must be created (name, tag, description)
1736
+ 2. Environments must be added (each environment slug → base URL for that stage)
1737
+ 3. Auth scheme must be configured (how outbound requests authenticate: apikey, bearer, OAuth2, etc.)
1738
+ 4. Action endpoints must be defined (each action = one HTTP endpoint spec: method, path, body/query/header shape, response shape)
1739
+ 5. The App must be connected to the product (product.apps.add) and its envs mapped
1740
+
1741
+ ONLY after all five steps can any code call:
1742
+ ctx.api.run({ app: '<app_tag>', event: '<action_tag>', input: { ... } }) ← in a feature handler
1743
+ actions.run([{ product, env, app: '<app_tag>', action: '<action_tag>', input }]) ← at runtime
1744
+
1745
+ ctx.api (alias for ctx.action) is NOT a generic HTTP call. It ONLY invokes a pre-registered
1746
+ Ductape App Action. If the App or action tag does not exist in the product, the call will fail.
1747
+ actions.dispatch is the same as actions.run but scheduled as a background job — it still requires
1748
+ a registered App. There is no way to dispatch a job to an arbitrary URL via ctx.api or actions.dispatch.
1749
+
1750
+ If a feature step needs to call an external service and no App is registered for it yet:
1751
+ → Flag it as "App to create" in your plan (STEP 4 of the feature design workflow)
1752
+ → Create the App and all its actions first (see below)
1753
+ → Only then write the ctx.api.run call
1754
+
1730
1755
  An app is a versioned API integration definition. It contains environments (base URLs), actions
1731
1756
  (individual endpoint specs), auth schemes, webhooks, variables, and constants.
1732
1757
 
@@ -1742,6 +1767,21 @@ Manage environments (base URLs per stage):
1742
1767
  ductape_execute("app.environments.create", [app_tag, { slug, env_name, base_url }])
1743
1768
  ductape_execute("app.environments.list", [app_tag])
1744
1769
 
1770
+ Discover apps in a product and their actions (ALWAYS do this before writing any ctx.api.run call):
1771
+ Step 1 — list apps connected to the product:
1772
+ ductape_cli("products get --tag <product_tag> --json") → full product document; check apps[]
1773
+ ductape_cli("products apps list --product <product_id> --json") → apps[] with access_tag, envs
1774
+ Step 2 — list actions in an app:
1775
+ ductape_execute("actions.list", [app_tag]) → returns all action tags + names
1776
+ Step 3 — fetch the input schema for an action:
1777
+ ductape_execute("actions.fetch", [app_tag, action_tag])
1778
+ → returns { body: {fieldName: {type, required}}, params: {}, query: {}, headers: {} }
1779
+ OR: call ductape_generate_payload (operation_family="action", method="run",
1780
+ targets={app: "app_tag", action: "action_tag"}) to get the exact resolved payload shape
1781
+ Step 4 — call ductape_schema({ module: "app" }) if you need the JSON schema for creating/updating
1782
+ app resources (not for runtime input — use actions.fetch or ductape_generate_payload for that)
1783
+ NEVER assume action input field names. Always fetch the action definition first.
1784
+
1745
1785
  Manage actions (individual API endpoints):
1746
1786
  ductape_execute("actions.create", [app_tag, {
1747
1787
  tag, name, resource: "/users/{id}", method: "GET"|"POST"|"PUT"|"PATCH"|"DELETE",
@@ -1755,6 +1795,16 @@ Manage actions (individual API endpoints):
1755
1795
  ductape_execute("actions.list", [app_tag])
1756
1796
  ductape_execute("actions.fetch", [app_tag, action_tag])
1757
1797
 
1798
+ Action input — flat input format:
1799
+ Fields are resolved to the correct location (body/params/query/headers) by matching the action schema.
1800
+ For ambiguous keys, use explicit prefixes:
1801
+ input: { amount: 1000 } → auto-resolved (body.amount if body field exists)
1802
+ input: { "body:amount": 1000 } → explicit body
1803
+ input: { "params:id": "user_123" } → route parameter
1804
+ input: { "query:limit": 10 } → query string
1805
+ input: { "headers:X-Idempotency-Key": "..." } → request header
1806
+ Always use ductape_generate_payload or actions.fetch to know the exact field names — never guess.
1807
+
1758
1808
  Run an action at runtime:
1759
1809
  → CALL ductape_generate_payload FIRST (operation_family="action", method="run", targets={app, action})
1760
1810
  ductape_execute("actions.run", [{ product, env, app, action, input: { "body:field": value } }])
@@ -1774,10 +1824,21 @@ Variables (per-env mutable values) and Constants (fixed values):
1774
1824
  ductape_execute("app.constants.create", [app_tag, { key, value }])
1775
1825
 
1776
1826
  Connecting an app to a product (after creation):
1777
- NOTE: All product.* module methods require the access key and CANNOT use ductape_execute (publishable key only).
1778
- Use ductape_cli for all product-level operations:
1779
- ductape_cli("products apps list --product <product_id> --json")
1780
- ductape_cli("products get --tag <product_tag> --json") ← includes apps[], databases[], features[] etc.
1827
+ NOTE: All product.* module methods require the access key and CANNOT use ductape_execute.
1828
+ Use ductape_cli for all product-level operations.
1829
+
1830
+ FULL FLOW to make an app callable from a product:
1831
+ 1. Create the app: ductape_cli("apps create --name \\"Service Name\\" --description \\"...\\"")
1832
+ 2. Add environments: ductape_execute("app.environments.create", [app_tag, { slug: "prd", env_name: "Production", base_url: "https://api.example.com" }])
1833
+ 3. Configure auth: ductape_execute("auths.create", [app_tag, { tag, name, setup_type: "apikey"|"bearer"|"basic"|"oauth2", expiry, period }])
1834
+ 4. Define actions: ductape_execute("actions.create", [app_tag, { tag, name, resource, method, body?, params?, query?, headers?, response? }])
1835
+ OR import: ductape_cli("apps import <file.json> -t postman|openapi")
1836
+ 5. Connect to product: Requires the product_id (from ductape_cli("products get --tag <tag> --json")).
1837
+ There is no CLI command for this step — the SDK product.apps.add method requires
1838
+ an access key which only the backend can provide. Use ductape_execute via an
1839
+ admin-authenticated context, or connect via the Workbench UI.
1840
+ 6. Verify: ductape_cli("products get --tag <product_tag> --json") → check apps[] contains the app
1841
+ ductape_execute("actions.list", [app_tag]) → verify actions are registered
1781
1842
  `.trim(),
1782
1843
  products: `
1783
1844
  DUCTAPE PRODUCTS
@@ -2086,7 +2147,13 @@ STEP 2 — INVENTORY existing Ductape components
2086
2147
  (product.* requires the access key — never use ductape_execute for product reads, it will return 403)
2087
2148
  Note what already exists:
2088
2149
  - databases[] → available for ctx.database.insert/query/update/delete steps
2089
- - apps[] → available for ctx.api.run steps (check app.events[] for event tags)
2150
+ - apps[] → available for ctx.api.run steps ONLY if the App is fully registered:
2151
+ (a) App record exists, (b) environments defined with base URLs,
2152
+ (c) auth scheme configured, (d) action endpoints defined,
2153
+ (e) connected to the product via product.apps.add.
2154
+ Check app.events[] for action tags. If no app exists for a service the
2155
+ feature needs to call, flag it as "App to create" in the plan — do NOT
2156
+ assume ctx.api can call any URL or schedule any job without a registered App.
2090
2157
  - notifications[] → available for ctx.notification.email/sms/push steps
2091
2158
  - storage[] → available for ctx.storage.upload/download steps
2092
2159
  - messageBrokers[] → available for ctx.events.produce steps
@@ -2094,10 +2161,14 @@ STEP 2 — INVENTORY existing Ductape components
2094
2161
  - features[] → can be called as child features via ctx.feature.execute()
2095
2162
  - caches[], sessions[]
2096
2163
  Do NOT assume a component or event tag exists — verify from the product before using it.
2164
+ Do NOT treat ctx.api as a generic HTTP call or job scheduler. It requires a registered App.
2097
2165
 
2098
2166
  STEP 3 — PLAN each step
2099
2167
  For every logical step:
2100
- a. Identify which existing component handles it, or flag it as needing creation
2168
+ a. Identify which existing component handles it, or flag it as needing creation.
2169
+ If a step calls an external service, it MUST go through a registered Ductape App.
2170
+ If no App for that service exists in the product → mark it "App to create: <service name>".
2171
+ DO NOT plan a raw HTTP call, a direct dispatch to a URL, or any workaround in place of a missing App.
2101
2172
  b. Note how inputs flow: ctx.input fields, or return values from earlier steps (plain JS variables — no special notation needed)
2102
2173
  c. Decide if a rollback handler is needed (e.g. charge → refund on later failure)
2103
2174
  d. Decide allow_fail: true for non-critical steps (email, analytics, audit logs)
@@ -2141,7 +2212,8 @@ STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
2141
2212
  → Only the scenario whose input matches runs at execution time
2142
2213
 
2143
2214
  STEP 8 — SET rollbacks for reversible steps
2144
- Any step that allocates a resource should undo it if a later step fails:
2215
+ Any step that allocates a resource should undo it if a later step fails.
2216
+ ctx.api.run requires a pre-registered Ductape App — 'stripe' below is the tag of a registered App:
2145
2217
  const charge = await ctx.step(
2146
2218
  'charge',
2147
2219
  async () => ctx.api.run({ app: 'stripe', event: 'create-charge', input: { amount: ctx.input.amount } }),
@@ -2201,6 +2273,52 @@ Signals and queries (for long-running features):
2201
2273
 
2202
2274
  Rollback strategies: reverse_all | reverse_critical | compensate | none
2203
2275
  Feature statuses: pending | running | completed | failed | rolled_back | rolling_back | paused
2276
+
2277
+ ━━━ FEATURE RECORDING SEMANTICS ━━━
2278
+
2279
+ When you call features.define({ handler }), the handler runs TWICE:
2280
+
2281
+ 1. RECORDING PHASE (at define time) — handler is called with a RecordingContext.
2282
+ All ctx.step() calls return lightweight proxy objects, not real data.
2283
+ This phase captures the step graph: which steps exist, their types, tags, and declared
2284
+ inputs/outputs. No real API calls, DB queries, or side effects occur.
2285
+ Arbitrary JS code OUTSIDE ctx.step() ALSO runs during recording — with proxy values.
2286
+ For loops: use recordInput so the handler sees sample data and all iterations are recorded.
2287
+ For branches: use branchOverrides so each path is captured.
2288
+
2289
+ 2. EXECUTION PHASE (at runtime) — handler is called with a real ExecutionContext.
2290
+ ctx.step() actually executes. All real Ductape component calls happen.
2291
+ Arbitrary JS logic (math, string ops, conditionals on step results) runs for real.
2292
+
2293
+ Implication: put all meaningful business logic INSIDE ctx.step() handlers, not in the
2294
+ outer handler body. Code in the outer body runs during recording with proxy values and
2295
+ may behave unexpectedly (e.g. typeof proxy === 'object' is true but .someField is a proxy).
2296
+
2297
+ Features do NOT execute arbitrary NestJS or server code directly. A feature handler can only
2298
+ call Ductape component primitives (ctx.api, ctx.database, ctx.notification, etc.) as steps.
2299
+ To invoke internal application business logic, produce a broker event from a feature step
2300
+ (ctx.messaging.produce) and consume it in your NestJS service — that is the correct pattern.
2301
+
2302
+ ━━━ ORCHESTRATION DECISION RULE ━━━
2303
+
2304
+ One component operation at a future time:
2305
+ → use that component's own dispatch method
2306
+ e.g. ductape.events.dispatch({ ..., schedule: { start_at: ... } })
2307
+ e.g. ductape.api.dispatch({ ..., schedule: { start_at: ... } })
2308
+ e.g. ductape.database.dispatch({ ..., schedule: { start_at: ... } })
2309
+
2310
+ Several durable Ductape component operations in sequence (with rollback / retry / state):
2311
+ → define a Feature, then features.dispatch to schedule it
2312
+
2313
+ Invoke internal application business logic (your own NestJS/backend service code):
2314
+ → produce a broker event (ctx.messaging.produce or ductape.events.produce)
2315
+ → consume it in your NestJS service with events.consume in onModuleInit
2316
+ → your service method runs with full access to DI, DB transactions, etc.
2317
+ Do NOT create an App Action just to call your own service over HTTP.
2318
+
2319
+ Invoke an external/public HTTP service:
2320
+ → create a Ductape App (register base URL, auth, action endpoints) then use ctx.api.run
2321
+ → requires the App to be fully registered and connected to the product first
2204
2322
  `.trim(),
2205
2323
  events: `
2206
2324
  DUCTAPE EVENTS (MESSAGE BROKERS)
@@ -2298,81 +2416,185 @@ Import (register an EXISTING cloud resource):
2298
2416
  redis: { host: "...", port: 6379, password?: "..." }
2299
2417
  nats: { servers: ["nats://host:4222"], token?: "...", user?: "...", pass?: "...", tls?: true }
2300
2418
 
2301
- ━━━ STEP 2: ADD TOPIC DEFINITIONS ━━━
2419
+ ━━━ STEP 2: DEFINE TOPICS ━━━
2302
2420
 
2303
- For most providers (GCP Pub/Sub, Kafka, RabbitMQ, Redis, NATS, Azure Service Bus):
2304
- SKIP this step. Topics are auto-created when you create a producer (step 3).
2305
- You do NOT need to call messageBrokers.topics.create before creating a producer.
2421
+ Topics MUST be defined before any consumer can subscribe to them.
2422
+ Producing to a topic also calls ensureTopicRegistered in the background but DO NOT rely on
2423
+ auto-registration for consume paths. Always create topics explicitly.
2306
2424
 
2307
- Only call topics.create explicitly when:
2308
- - Using AWS SQS (must supply queueUrls per env auto-creation cannot know the queue URL)
2309
- - You want to pre-set sample data or idempotency config on the topic
2425
+ IMPORTANT: messageBrokers.topics.create requires an access key (admin operation).
2426
+ Use ductape_cli NOT ductape_executeto create topics.
2310
2427
 
2311
- If you do need it (SQS or explicit config):
2312
- ductape_execute("messageBrokers.topics.create", [product_tag, {
2313
- tag: "player-joined",
2314
- name: "Player Joined",
2315
- broker: "notifications-broker", // broker component tag
2316
- description?: string,
2317
- sample: { playerId: "string", username: "string" }, // example message shape
2318
- idempotent?: boolean,
2319
- // SQS only — map per-env queue URLs (each topic can be a different queue):
2320
- queueUrls?: [
2321
- { env_slug: "snd", url: "https://sqs.us-east-1.amazonaws.com/123456/my-product-player-joined-snd" },
2322
- { env_slug: "prd", url: "https://sqs.us-east-1.amazonaws.com/123456/my-product-player-joined-prd" }
2323
- ]
2324
- }])
2428
+ Write a topic.json file, then:
2429
+ ductape_cli("events topics create -f topic.json")
2430
+
2431
+ topic.json schema:
2432
+ {
2433
+ "tag": "order-created", // topic tag only — NOT "broker:topic"
2434
+ "name": "Order Created",
2435
+ "broker": "order-events", // broker component tag
2436
+ "description": "...", // optional
2437
+ "sample": { "orderId": "string", "total": 0 }, // expected message shape
2438
+ "idempotent": false, // optional deduplicates by idempotency_key when true
2439
+ // AWS SQS only — per-env queue URL:
2440
+ "queueUrls": [
2441
+ { "env_slug": "snd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-snd" },
2442
+ { "env_slug": "prd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-prd" }
2443
+ ]
2444
+ }
2325
2445
 
2326
- List topics on a broker:
2327
- ductape_execute("messageBrokers.topics.list", [product_tag, "broker-tag"])
2446
+ Other topic operations (all require access key via ductape_cli):
2447
+ ductape_cli("events topics list --tag order-events") → list topics for a broker
2448
+ ductape_cli("events topics get --tag order-events:order-created")
2449
+ ductape_cli("events topics update --tag order-events:order-created -f patch.json")
2450
+ ductape_cli("events topics delete --tag order-events:order-created")
2328
2451
 
2329
- ━━━ STEP 3: PRODUCE AND CONSUME WRITTEN IN APPLICATION CODE ━━━
2452
+ Read-only fetches (safe with publishable key via ductape_execute):
2453
+ ductape_execute("messageBrokers.fetch", [product_tag, "broker-tag"]) → includes topics[]
2330
2454
 
2331
- There is NO admin command or file to declare producers/consumers.
2332
- There is NO "create producer" step before writing code.
2333
- Producers and consumers are registered automatically by the SDK the first time your code calls
2334
- produce/consume — you do not pre-declare them.
2455
+ ━━━ STEP 3: PRODUCE WRITTEN IN APPLICATION CODE ━━━
2335
2456
 
2336
- The entire producer/consumer contract is the code you write in your controllers or services:
2457
+ There is NO admin command to declare a producer. Producers are auto-registered by the SDK on
2458
+ the first produce call — you do not pre-declare them.
2459
+ Do NOT call ductape_generate_payload for messaging. The producer owns the schema.
2460
+ Infer the message shape from context, present it to the user for approval, then implement.
2337
2461
 
2338
- Produce (publish a message) write in your service/controller:
2339
- Do NOT call ductape_generate_payload for messaging. Events have no pre-existing backend schema
2340
- to discover the producer defines the schema. Instead, infer the message shape from context
2341
- (event name, existing data models, user input), present it to the user for approval, then implement.
2462
+ GENERAL BACKEND (TypeScript/Node.jsnot NestJS):
2463
+ import Ductape from '@ductape/sdk';
2464
+ const ductape = new Ductape({ accessKey: 'your-access-key' });
2342
2465
  await ductape.events.produce({
2343
2466
  product: "my-product",
2344
2467
  env: "prd",
2345
- event: "broker_tag:topic_tag", // "broker_tag:topic_tag" — always colon-separated
2346
- message: { key: value }, // shape inferred from context, approved by user
2468
+ event: "broker-tag:topic-tag", // always colon-separated
2469
+ message: { key: value },
2470
+ session?: "session-tag:jwt", // optional — traces message to a user session
2347
2471
  });
2348
- Idempotent publish (deduplicates by key):
2349
- await ductape.events.publishIdempotent({ product, env, event, message, idempotencyKey, idempotencyTtl? })
2472
+ // Idempotent publish (deduplicates prevents double-processing on retries):
2473
+ await ductape.events.publishIdempotent({
2474
+ product, env, event, message,
2475
+ idempotencyKey: "order-123-charge", // stable key unique to this logical operation
2476
+ idempotencyTtl?: 86400, // seconds; default 86400 (24h)
2477
+ });
2478
+
2479
+ NESTJS — method decorator:
2480
+ import { Events } from '@ductape/nestjs';
2481
+ @Injectable() export class OrdersService {
2482
+ @Events.Produce({ event: 'order-events:order-created' })
2483
+ emitOrderCreated(payload: { orderId: string; total: number }) { return payload; }
2350
2484
 
2351
- Consume (subscribe)write in your service/controller:
2485
+ // Scheduled dispatch fire-and-forget with optional schedule:
2486
+ @Events.Dispatch({ broker: 'order-events', event: 'order-events:order-created',
2487
+ schedule?: { start_at?, cron?, every?, limit?, tz? } })
2488
+ scheduleOrderNotification(payload: Record<string, unknown>) { return payload; }
2489
+ }
2490
+
2491
+ CLIENT-SIDE (browser — publishable key):
2492
+ Clients CAN produce messages using a publishable key + session token.
2493
+ Only produce to topics whose schema is safe for client authorship.
2494
+ NEVER allow clients to produce to topics that trigger privileged server-side operations
2495
+ (payments, admin actions, state mutations) — those must go through a backend endpoint first.
2496
+ import Ductape from '@ductape/sdk';
2497
+ const ductape = new Ductape({ publishableKey: 'pk_...', env: 'prd', product: 'my-product' });
2498
+ await ductape.events.produce({
2499
+ event: "user-events:user-action",
2500
+ message: { action: "button-click", screen: "dashboard" },
2501
+ session: "user-session:eyJ...", // REQUIRED for client-side produce
2502
+ });
2503
+
2504
+ SCHEDULED DISPATCH (background job):
2505
+ ductape_execute("messageBrokers.dispatch", [{
2506
+ product, env,
2507
+ broker: "order-events", // broker tag
2508
+ event: "order-events:reminder-due", // "broker:topic"
2509
+ input: { message: { orderId: "123" } },
2510
+ retries?: 3,
2511
+ session?: "session-tag:jwt",
2512
+ schedule?: {
2513
+ start_at?: 1735689600000, // Unix ms or ISO string
2514
+ cron?: "0 9 * * *", // recurring cron
2515
+ every?: 86400000, // recurring interval ms
2516
+ limit?: 10, // max repetitions
2517
+ endDate?: "2026-12-31",
2518
+ tz?: "America/New_York",
2519
+ },
2520
+ }])
2521
+ Returns: { job_id, status: "scheduled"|"queued", scheduled_at, recurring, next_run_at? }
2522
+
2523
+ ━━━ STEP 4: CONSUME — WRITTEN IN APPLICATION CODE ━━━
2524
+
2525
+ Consumers are auto-registered by the SDK on first consume call.
2526
+ Consumer registration options (all optional — used for tracking in Workbench):
2527
+ consumer?: { tag?: string, name?: string, description?: string }
2528
+ If tag is omitted, Ductape generates one: "consumer-<brokerTag>-<topicTag>".
2529
+
2530
+ ACK BEHAVIOR (automatic):
2531
+ - Callback returns successfully → message is acknowledged (ack)
2532
+ - Callback throws → message is tracked as failed; broker nacks/retries per provider behavior
2533
+ - After max retries → message moves to dead-letter queue (DLQ)
2534
+ There is no manual ack API. Acknowledgement is implicit from callback outcome.
2535
+
2536
+ CONSUMER GROUPS (Kafka-specific):
2537
+ Consumer groups are set in the broker's envs[].config.groupId (at broker registration time).
2538
+ All service instances sharing the same groupId form a consumer group and share partition load.
2539
+ To configure: set groupId in the kafka config when creating/updating the broker.
2540
+
2541
+ CONCURRENCY:
2542
+ Ductape has no per-consumer concurrency setting. Concurrency is determined by:
2543
+ - Number of running service instances (horizontal scale)
2544
+ - Broker-level partition count (Kafka) or visibility timeout (SQS)
2545
+ Run multiple instances of your service to scale consumption.
2546
+
2547
+ GENERAL BACKEND (TypeScript/Node.js — not NestJS):
2548
+ Start consuming in your module init or service startup:
2352
2549
  await ductape.events.consume({
2353
2550
  product: "my-product",
2354
2551
  env: "prd",
2355
- event: "broker_tag:topic_tag",
2356
- callback: async (message) => { /* handle message */ },
2552
+ event: "order-events:order-created",
2553
+ callback: async (message) => {
2554
+ // All real processing logic goes here.
2555
+ // Throw to nack. Return to ack.
2556
+ await processOrder(message as { orderId: string; total: number });
2557
+ },
2558
+ consumer?: { tag: "order-processor", name: "Order Processor" },
2357
2559
  });
2358
- Callback errors are re-thrown so the broker can nack/retry.
2359
2560
 
2360
- Background dispatch with scheduling write in your service/controller:
2361
- await ductape.events.dispatch({ product, env, broker, event, input: { message },
2362
- schedule?: { start_at?, cron?, every?, limit?, endDate?, tz? } })
2561
+ NESTJS use SDK in onModuleInit (no @Events.Consume decorator exists yet):
2562
+ @Injectable()
2563
+ export class OrderConsumerService implements OnModuleInit {
2564
+ constructor(private readonly ductape: Ductape) {}
2565
+ async onModuleInit() {
2566
+ await this.ductape.events.consume({
2567
+ product: "my-product",
2568
+ env: process.env.DUCTAPE_ENV || 'prd',
2569
+ event: "order-events:order-created",
2570
+ callback: async (message) => { await this.handle(message); },
2571
+ consumer: { tag: "order-consumer", name: "Order Consumer" },
2572
+ });
2573
+ }
2574
+ private async handle(message: unknown) { /* business logic */ }
2575
+ }
2363
2576
 
2364
- For the four standard producer declarations (match-state, match-report, projection-updated,
2365
- notification), write these produce calls in the relevant application service methods there is
2366
- no separate configuration file or CLI step. The SDK creates the producer metadata on first call.
2577
+ CLIENT-SIDE: Clients CANNOT consume. Event consumption is always server-side only.
2578
+ This is the key distinction between server topics (produce + consume) and client-observable
2579
+ topics (produce from client, consume on server). Never set up a consumer in browser code.
2367
2580
 
2368
- Message payload is AES-encrypted before the tracking API call — tracking never sees plaintext.
2581
+ DEAD-LETTER QUEUE (DLQ):
2582
+ Messages whose callbacks consistently throw are automatically moved to the DLQ.
2583
+ Query: ductape_execute("messageBrokers.messages.getDeadLetters",
2584
+ [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }])
2585
+ Reprocess: ductape_execute("messageBrokers.reprocessDLQ",
2586
+ [{ product, env, brokerTag, topicTag?, messageIds?, limit? }])
2587
+ Replay: ductape_execute("messageBrokers.replayEvent",
2588
+ [{ product, env, eventId, force? }])
2369
2589
 
2370
2590
  ━━━ OBSERVABILITY ━━━
2371
2591
 
2372
2592
  messageBrokers.messages.query [{ product, env, brokerTag, topicTag?, status?, page?, limit? }]
2373
2593
  messageBrokers.messages.getStats [{ product, env, brokerTag }]
2374
2594
  messageBrokers.messages.getDashboard [{ product, env, brokerTag }]
2375
- messageBrokers.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, limit? }]
2595
+ messageBrokers.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }]
2596
+ messageBrokers.messages.getProducers [{ product, env, brokerTag, topicTag?, page?, limit? }]
2597
+ messageBrokers.messages.getConsumers [{ product, env, brokerTag, topicTag?, page?, limit? }]
2376
2598
  messageBrokers.replayEvent [{ product, env, eventId, force? }]
2377
2599
  messageBrokers.reprocessDLQ [{ product, env, brokerTag, topicTag?, messageIds?, limit? }]
2378
2600
  messageBrokers.checkIdempotency [{ product, env, brokerTag, idempotency_key }]
@@ -3313,7 +3535,7 @@ async function main() {
3313
3535
  ' GCP Pub/Sub service identifier is "pubsub". AWS SQS is "sqs". Azure Service Bus is "servicebus".\n' +
3314
3536
  ' Message brokers are import-only (no provision-persist). Import flow is the same as storage.\n' +
3315
3537
  ' type field = "messageBrokers" (not "messagebrokers" or "events").\n' +
3316
- ' After importing, create producers topics are auto-created with the producer (except SQS, which needs explicit topics.create with queueUrls first).\n' +
3538
+ ' After importing, create topics first with ductape_cli("events topics create -f topic.json") — SQS requires explicit topic creation with queueUrls. For other providers, topics auto-register on first produce but should still be created explicitly before any consumer subscribes.\n' +
3317
3539
  ' - Listing workspaces, products, secrets\n' +
3318
3540
  ' - Linking a project folder: "link --product <tag> --env <slug>"\n' +
3319
3541
  ' - Syncing sessions/notifications/events: "apply" or "apply sessions" etc.\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/mcp",
3
- "version": "0.1.38",
3
+ "version": "0.1.40",
4
4
  "description": "MCP server that exposes Ductape SDK operations via the backend proxy",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -475,18 +475,18 @@ ALL params are passed as a JSON array in positional order matching the SDK signa
475
475
  messageBrokers.fetch [product_tag, broker_tag]
476
476
  messageBrokers.list [product_tag]
477
477
  messageBrokers.delete [product_tag, broker_tag]
478
- messageBrokers.topics.create [product_tag, data: { tag: string, name: string, broker: string,
479
- description?: string, sample?: object, idempotent?: boolean,
480
- queueUrls?: [{ env_slug: string, url: string }] // SQS only: per-env queue URL per topic
481
- }]
482
- OPTIONAL for most providers: creating a producer automatically creates the topic if it does not exist.
483
- Only required explicitly for SQS (must supply queueUrls per env) or when you want to set sample/idempotent upfront.
484
- For Pub/Sub, Kafka, RabbitMQ, Redis, NATS: skip this — let producer creation handle it.
485
- A broker can have unlimited topics. Add one per logical event type.
486
- messageBrokers.topics.update [product_tag, topic_tag, data: { name?: string, description?: string,
487
- sample?: object, idempotent?: boolean, queueUrls?: [{ env_slug: string, url: string }] }]
488
- messageBrokers.topics.fetch [product_tag, topic_tag]
489
- messageBrokers.topics.list [product_tag, broker_tag]
478
+ messageBrokers.topics.create FORBIDDEN with publishable key. Use ductape_cli instead:
479
+ ductape_cli("events topics create -f topic.json")
480
+ topic.json: { tag, name, broker, description?, sample?, idempotent?, queueUrls?: [{ env_slug, url }] }
481
+ ← Always required before consuming. For SQS: must include queueUrls per env.
482
+ For Pub/Sub, Kafka, RabbitMQ, Redis, NATS: the first produce call auto-registers the topic,
483
+ but you should still create it explicitly so consumers can subscribe before any produce occurs.
484
+ messageBrokers.topics.update FORBIDDEN with publishable key. Use ductape_cli:
485
+ ductape_cli("events topics update --tag broker:topic -f patch.json")
486
+ messageBrokers.topics.delete ← FORBIDDEN with publishable key. Use ductape_cli:
487
+ ductape_cli("events topics delete --tag broker:topic")
488
+ messageBrokers.topics.fetch [product_tag, topic_tag] ← safe via ductape_execute
489
+ messageBrokers.topics.list [product_tag, broker_tag] ← safe via ductape_execute
490
490
  messageBrokers.produce [{ product, env, event: "broker_tag:topic_tag", message: { key: value }, session?, cache? }]
491
491
  messageBrokers.consume [{ product, env, event: "broker_tag:topic_tag", callback: "function_ref" }]
492
492
  messageBrokers.dispatch [{ product, env, broker, event, input: { message }, retries?, session?, cache?, schedule?: { cron?, every?, start_at? } }]
@@ -1793,6 +1793,31 @@ Important:
1793
1793
  apps: `
1794
1794
  DUCTAPE APPS
1795
1795
 
1796
+ WHAT AN APP IS:
1797
+ A Ductape App is a pre-configured, versioned API integration definition. It is NOT a generic HTTP
1798
+ client, NOT a job scheduler, and NOT anything you can call without registering first.
1799
+
1800
+ An App must be fully set up in Ductape before any code can use it:
1801
+ 1. The App record must be created (name, tag, description)
1802
+ 2. Environments must be added (each environment slug → base URL for that stage)
1803
+ 3. Auth scheme must be configured (how outbound requests authenticate: apikey, bearer, OAuth2, etc.)
1804
+ 4. Action endpoints must be defined (each action = one HTTP endpoint spec: method, path, body/query/header shape, response shape)
1805
+ 5. The App must be connected to the product (product.apps.add) and its envs mapped
1806
+
1807
+ ONLY after all five steps can any code call:
1808
+ ctx.api.run({ app: '<app_tag>', event: '<action_tag>', input: { ... } }) ← in a feature handler
1809
+ actions.run([{ product, env, app: '<app_tag>', action: '<action_tag>', input }]) ← at runtime
1810
+
1811
+ ctx.api (alias for ctx.action) is NOT a generic HTTP call. It ONLY invokes a pre-registered
1812
+ Ductape App Action. If the App or action tag does not exist in the product, the call will fail.
1813
+ actions.dispatch is the same as actions.run but scheduled as a background job — it still requires
1814
+ a registered App. There is no way to dispatch a job to an arbitrary URL via ctx.api or actions.dispatch.
1815
+
1816
+ If a feature step needs to call an external service and no App is registered for it yet:
1817
+ → Flag it as "App to create" in your plan (STEP 4 of the feature design workflow)
1818
+ → Create the App and all its actions first (see below)
1819
+ → Only then write the ctx.api.run call
1820
+
1796
1821
  An app is a versioned API integration definition. It contains environments (base URLs), actions
1797
1822
  (individual endpoint specs), auth schemes, webhooks, variables, and constants.
1798
1823
 
@@ -1808,6 +1833,21 @@ Manage environments (base URLs per stage):
1808
1833
  ductape_execute("app.environments.create", [app_tag, { slug, env_name, base_url }])
1809
1834
  ductape_execute("app.environments.list", [app_tag])
1810
1835
 
1836
+ Discover apps in a product and their actions (ALWAYS do this before writing any ctx.api.run call):
1837
+ Step 1 — list apps connected to the product:
1838
+ ductape_cli("products get --tag <product_tag> --json") → full product document; check apps[]
1839
+ ductape_cli("products apps list --product <product_id> --json") → apps[] with access_tag, envs
1840
+ Step 2 — list actions in an app:
1841
+ ductape_execute("actions.list", [app_tag]) → returns all action tags + names
1842
+ Step 3 — fetch the input schema for an action:
1843
+ ductape_execute("actions.fetch", [app_tag, action_tag])
1844
+ → returns { body: {fieldName: {type, required}}, params: {}, query: {}, headers: {} }
1845
+ OR: call ductape_generate_payload (operation_family="action", method="run",
1846
+ targets={app: "app_tag", action: "action_tag"}) to get the exact resolved payload shape
1847
+ Step 4 — call ductape_schema({ module: "app" }) if you need the JSON schema for creating/updating
1848
+ app resources (not for runtime input — use actions.fetch or ductape_generate_payload for that)
1849
+ NEVER assume action input field names. Always fetch the action definition first.
1850
+
1811
1851
  Manage actions (individual API endpoints):
1812
1852
  ductape_execute("actions.create", [app_tag, {
1813
1853
  tag, name, resource: "/users/{id}", method: "GET"|"POST"|"PUT"|"PATCH"|"DELETE",
@@ -1821,6 +1861,16 @@ Manage actions (individual API endpoints):
1821
1861
  ductape_execute("actions.list", [app_tag])
1822
1862
  ductape_execute("actions.fetch", [app_tag, action_tag])
1823
1863
 
1864
+ Action input — flat input format:
1865
+ Fields are resolved to the correct location (body/params/query/headers) by matching the action schema.
1866
+ For ambiguous keys, use explicit prefixes:
1867
+ input: { amount: 1000 } → auto-resolved (body.amount if body field exists)
1868
+ input: { "body:amount": 1000 } → explicit body
1869
+ input: { "params:id": "user_123" } → route parameter
1870
+ input: { "query:limit": 10 } → query string
1871
+ input: { "headers:X-Idempotency-Key": "..." } → request header
1872
+ Always use ductape_generate_payload or actions.fetch to know the exact field names — never guess.
1873
+
1824
1874
  Run an action at runtime:
1825
1875
  → CALL ductape_generate_payload FIRST (operation_family="action", method="run", targets={app, action})
1826
1876
  ductape_execute("actions.run", [{ product, env, app, action, input: { "body:field": value } }])
@@ -1840,10 +1890,21 @@ Variables (per-env mutable values) and Constants (fixed values):
1840
1890
  ductape_execute("app.constants.create", [app_tag, { key, value }])
1841
1891
 
1842
1892
  Connecting an app to a product (after creation):
1843
- NOTE: All product.* module methods require the access key and CANNOT use ductape_execute (publishable key only).
1844
- Use ductape_cli for all product-level operations:
1845
- ductape_cli("products apps list --product <product_id> --json")
1846
- ductape_cli("products get --tag <product_tag> --json") ← includes apps[], databases[], features[] etc.
1893
+ NOTE: All product.* module methods require the access key and CANNOT use ductape_execute.
1894
+ Use ductape_cli for all product-level operations.
1895
+
1896
+ FULL FLOW to make an app callable from a product:
1897
+ 1. Create the app: ductape_cli("apps create --name \\"Service Name\\" --description \\"...\\"")
1898
+ 2. Add environments: ductape_execute("app.environments.create", [app_tag, { slug: "prd", env_name: "Production", base_url: "https://api.example.com" }])
1899
+ 3. Configure auth: ductape_execute("auths.create", [app_tag, { tag, name, setup_type: "apikey"|"bearer"|"basic"|"oauth2", expiry, period }])
1900
+ 4. Define actions: ductape_execute("actions.create", [app_tag, { tag, name, resource, method, body?, params?, query?, headers?, response? }])
1901
+ OR import: ductape_cli("apps import <file.json> -t postman|openapi")
1902
+ 5. Connect to product: Requires the product_id (from ductape_cli("products get --tag <tag> --json")).
1903
+ There is no CLI command for this step — the SDK product.apps.add method requires
1904
+ an access key which only the backend can provide. Use ductape_execute via an
1905
+ admin-authenticated context, or connect via the Workbench UI.
1906
+ 6. Verify: ductape_cli("products get --tag <product_tag> --json") → check apps[] contains the app
1907
+ ductape_execute("actions.list", [app_tag]) → verify actions are registered
1847
1908
  `.trim(),
1848
1909
 
1849
1910
  products: `
@@ -2158,7 +2219,13 @@ STEP 2 — INVENTORY existing Ductape components
2158
2219
  (product.* requires the access key — never use ductape_execute for product reads, it will return 403)
2159
2220
  Note what already exists:
2160
2221
  - databases[] → available for ctx.database.insert/query/update/delete steps
2161
- - apps[] → available for ctx.api.run steps (check app.events[] for event tags)
2222
+ - apps[] → available for ctx.api.run steps ONLY if the App is fully registered:
2223
+ (a) App record exists, (b) environments defined with base URLs,
2224
+ (c) auth scheme configured, (d) action endpoints defined,
2225
+ (e) connected to the product via product.apps.add.
2226
+ Check app.events[] for action tags. If no app exists for a service the
2227
+ feature needs to call, flag it as "App to create" in the plan — do NOT
2228
+ assume ctx.api can call any URL or schedule any job without a registered App.
2162
2229
  - notifications[] → available for ctx.notification.email/sms/push steps
2163
2230
  - storage[] → available for ctx.storage.upload/download steps
2164
2231
  - messageBrokers[] → available for ctx.events.produce steps
@@ -2166,10 +2233,14 @@ STEP 2 — INVENTORY existing Ductape components
2166
2233
  - features[] → can be called as child features via ctx.feature.execute()
2167
2234
  - caches[], sessions[]
2168
2235
  Do NOT assume a component or event tag exists — verify from the product before using it.
2236
+ Do NOT treat ctx.api as a generic HTTP call or job scheduler. It requires a registered App.
2169
2237
 
2170
2238
  STEP 3 — PLAN each step
2171
2239
  For every logical step:
2172
- a. Identify which existing component handles it, or flag it as needing creation
2240
+ a. Identify which existing component handles it, or flag it as needing creation.
2241
+ If a step calls an external service, it MUST go through a registered Ductape App.
2242
+ If no App for that service exists in the product → mark it "App to create: <service name>".
2243
+ DO NOT plan a raw HTTP call, a direct dispatch to a URL, or any workaround in place of a missing App.
2173
2244
  b. Note how inputs flow: ctx.input fields, or return values from earlier steps (plain JS variables — no special notation needed)
2174
2245
  c. Decide if a rollback handler is needed (e.g. charge → refund on later failure)
2175
2246
  d. Decide allow_fail: true for non-critical steps (email, analytics, audit logs)
@@ -2213,7 +2284,8 @@ STEP 7 — HANDLE conditionals, loops, and branching (when applicable)
2213
2284
  → Only the scenario whose input matches runs at execution time
2214
2285
 
2215
2286
  STEP 8 — SET rollbacks for reversible steps
2216
- Any step that allocates a resource should undo it if a later step fails:
2287
+ Any step that allocates a resource should undo it if a later step fails.
2288
+ ctx.api.run requires a pre-registered Ductape App — 'stripe' below is the tag of a registered App:
2217
2289
  const charge = await ctx.step(
2218
2290
  'charge',
2219
2291
  async () => ctx.api.run({ app: 'stripe', event: 'create-charge', input: { amount: ctx.input.amount } }),
@@ -2273,6 +2345,52 @@ Signals and queries (for long-running features):
2273
2345
 
2274
2346
  Rollback strategies: reverse_all | reverse_critical | compensate | none
2275
2347
  Feature statuses: pending | running | completed | failed | rolled_back | rolling_back | paused
2348
+
2349
+ ━━━ FEATURE RECORDING SEMANTICS ━━━
2350
+
2351
+ When you call features.define({ handler }), the handler runs TWICE:
2352
+
2353
+ 1. RECORDING PHASE (at define time) — handler is called with a RecordingContext.
2354
+ All ctx.step() calls return lightweight proxy objects, not real data.
2355
+ This phase captures the step graph: which steps exist, their types, tags, and declared
2356
+ inputs/outputs. No real API calls, DB queries, or side effects occur.
2357
+ Arbitrary JS code OUTSIDE ctx.step() ALSO runs during recording — with proxy values.
2358
+ For loops: use recordInput so the handler sees sample data and all iterations are recorded.
2359
+ For branches: use branchOverrides so each path is captured.
2360
+
2361
+ 2. EXECUTION PHASE (at runtime) — handler is called with a real ExecutionContext.
2362
+ ctx.step() actually executes. All real Ductape component calls happen.
2363
+ Arbitrary JS logic (math, string ops, conditionals on step results) runs for real.
2364
+
2365
+ Implication: put all meaningful business logic INSIDE ctx.step() handlers, not in the
2366
+ outer handler body. Code in the outer body runs during recording with proxy values and
2367
+ may behave unexpectedly (e.g. typeof proxy === 'object' is true but .someField is a proxy).
2368
+
2369
+ Features do NOT execute arbitrary NestJS or server code directly. A feature handler can only
2370
+ call Ductape component primitives (ctx.api, ctx.database, ctx.notification, etc.) as steps.
2371
+ To invoke internal application business logic, produce a broker event from a feature step
2372
+ (ctx.messaging.produce) and consume it in your NestJS service — that is the correct pattern.
2373
+
2374
+ ━━━ ORCHESTRATION DECISION RULE ━━━
2375
+
2376
+ One component operation at a future time:
2377
+ → use that component's own dispatch method
2378
+ e.g. ductape.events.dispatch({ ..., schedule: { start_at: ... } })
2379
+ e.g. ductape.api.dispatch({ ..., schedule: { start_at: ... } })
2380
+ e.g. ductape.database.dispatch({ ..., schedule: { start_at: ... } })
2381
+
2382
+ Several durable Ductape component operations in sequence (with rollback / retry / state):
2383
+ → define a Feature, then features.dispatch to schedule it
2384
+
2385
+ Invoke internal application business logic (your own NestJS/backend service code):
2386
+ → produce a broker event (ctx.messaging.produce or ductape.events.produce)
2387
+ → consume it in your NestJS service with events.consume in onModuleInit
2388
+ → your service method runs with full access to DI, DB transactions, etc.
2389
+ Do NOT create an App Action just to call your own service over HTTP.
2390
+
2391
+ Invoke an external/public HTTP service:
2392
+ → create a Ductape App (register base URL, auth, action endpoints) then use ctx.api.run
2393
+ → requires the App to be fully registered and connected to the product first
2276
2394
  `.trim(),
2277
2395
 
2278
2396
  events: `
@@ -2371,81 +2489,185 @@ Import (register an EXISTING cloud resource):
2371
2489
  redis: { host: "...", port: 6379, password?: "..." }
2372
2490
  nats: { servers: ["nats://host:4222"], token?: "...", user?: "...", pass?: "...", tls?: true }
2373
2491
 
2374
- ━━━ STEP 2: ADD TOPIC DEFINITIONS ━━━
2492
+ ━━━ STEP 2: DEFINE TOPICS ━━━
2375
2493
 
2376
- For most providers (GCP Pub/Sub, Kafka, RabbitMQ, Redis, NATS, Azure Service Bus):
2377
- SKIP this step. Topics are auto-created when you create a producer (step 3).
2378
- You do NOT need to call messageBrokers.topics.create before creating a producer.
2494
+ Topics MUST be defined before any consumer can subscribe to them.
2495
+ Producing to a topic also calls ensureTopicRegistered in the background but DO NOT rely on
2496
+ auto-registration for consume paths. Always create topics explicitly.
2379
2497
 
2380
- Only call topics.create explicitly when:
2381
- - Using AWS SQS (must supply queueUrls per env auto-creation cannot know the queue URL)
2382
- - You want to pre-set sample data or idempotency config on the topic
2498
+ IMPORTANT: messageBrokers.topics.create requires an access key (admin operation).
2499
+ Use ductape_cli NOT ductape_executeto create topics.
2383
2500
 
2384
- If you do need it (SQS or explicit config):
2385
- ductape_execute("messageBrokers.topics.create", [product_tag, {
2386
- tag: "player-joined",
2387
- name: "Player Joined",
2388
- broker: "notifications-broker", // broker component tag
2389
- description?: string,
2390
- sample: { playerId: "string", username: "string" }, // example message shape
2391
- idempotent?: boolean,
2392
- // SQS only — map per-env queue URLs (each topic can be a different queue):
2393
- queueUrls?: [
2394
- { env_slug: "snd", url: "https://sqs.us-east-1.amazonaws.com/123456/my-product-player-joined-snd" },
2395
- { env_slug: "prd", url: "https://sqs.us-east-1.amazonaws.com/123456/my-product-player-joined-prd" }
2396
- ]
2397
- }])
2501
+ Write a topic.json file, then:
2502
+ ductape_cli("events topics create -f topic.json")
2503
+
2504
+ topic.json schema:
2505
+ {
2506
+ "tag": "order-created", // topic tag only — NOT "broker:topic"
2507
+ "name": "Order Created",
2508
+ "broker": "order-events", // broker component tag
2509
+ "description": "...", // optional
2510
+ "sample": { "orderId": "string", "total": 0 }, // expected message shape
2511
+ "idempotent": false, // optional deduplicates by idempotency_key when true
2512
+ // AWS SQS only — per-env queue URL:
2513
+ "queueUrls": [
2514
+ { "env_slug": "snd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-snd" },
2515
+ { "env_slug": "prd", "url": "https://sqs.us-east-1.amazonaws.com/123/queue-prd" }
2516
+ ]
2517
+ }
2398
2518
 
2399
- List topics on a broker:
2400
- ductape_execute("messageBrokers.topics.list", [product_tag, "broker-tag"])
2519
+ Other topic operations (all require access key via ductape_cli):
2520
+ ductape_cli("events topics list --tag order-events") → list topics for a broker
2521
+ ductape_cli("events topics get --tag order-events:order-created")
2522
+ ductape_cli("events topics update --tag order-events:order-created -f patch.json")
2523
+ ductape_cli("events topics delete --tag order-events:order-created")
2401
2524
 
2402
- ━━━ STEP 3: PRODUCE AND CONSUME WRITTEN IN APPLICATION CODE ━━━
2525
+ Read-only fetches (safe with publishable key via ductape_execute):
2526
+ ductape_execute("messageBrokers.fetch", [product_tag, "broker-tag"]) → includes topics[]
2403
2527
 
2404
- There is NO admin command or file to declare producers/consumers.
2405
- There is NO "create producer" step before writing code.
2406
- Producers and consumers are registered automatically by the SDK the first time your code calls
2407
- produce/consume — you do not pre-declare them.
2528
+ ━━━ STEP 3: PRODUCE WRITTEN IN APPLICATION CODE ━━━
2408
2529
 
2409
- The entire producer/consumer contract is the code you write in your controllers or services:
2530
+ There is NO admin command to declare a producer. Producers are auto-registered by the SDK on
2531
+ the first produce call — you do not pre-declare them.
2532
+ Do NOT call ductape_generate_payload for messaging. The producer owns the schema.
2533
+ Infer the message shape from context, present it to the user for approval, then implement.
2410
2534
 
2411
- Produce (publish a message) write in your service/controller:
2412
- Do NOT call ductape_generate_payload for messaging. Events have no pre-existing backend schema
2413
- to discover the producer defines the schema. Instead, infer the message shape from context
2414
- (event name, existing data models, user input), present it to the user for approval, then implement.
2535
+ GENERAL BACKEND (TypeScript/Node.jsnot NestJS):
2536
+ import Ductape from '@ductape/sdk';
2537
+ const ductape = new Ductape({ accessKey: 'your-access-key' });
2415
2538
  await ductape.events.produce({
2416
2539
  product: "my-product",
2417
2540
  env: "prd",
2418
- event: "broker_tag:topic_tag", // "broker_tag:topic_tag" — always colon-separated
2419
- message: { key: value }, // shape inferred from context, approved by user
2541
+ event: "broker-tag:topic-tag", // always colon-separated
2542
+ message: { key: value },
2543
+ session?: "session-tag:jwt", // optional — traces message to a user session
2544
+ });
2545
+ // Idempotent publish (deduplicates — prevents double-processing on retries):
2546
+ await ductape.events.publishIdempotent({
2547
+ product, env, event, message,
2548
+ idempotencyKey: "order-123-charge", // stable key unique to this logical operation
2549
+ idempotencyTtl?: 86400, // seconds; default 86400 (24h)
2420
2550
  });
2421
- Idempotent publish (deduplicates by key):
2422
- await ductape.events.publishIdempotent({ product, env, event, message, idempotencyKey, idempotencyTtl? })
2423
2551
 
2424
- Consume (subscribe) write in your service/controller:
2552
+ NESTJSmethod decorator:
2553
+ import { Events } from '@ductape/nestjs';
2554
+ @Injectable() export class OrdersService {
2555
+ @Events.Produce({ event: 'order-events:order-created' })
2556
+ emitOrderCreated(payload: { orderId: string; total: number }) { return payload; }
2557
+
2558
+ // Scheduled dispatch — fire-and-forget with optional schedule:
2559
+ @Events.Dispatch({ broker: 'order-events', event: 'order-events:order-created',
2560
+ schedule?: { start_at?, cron?, every?, limit?, tz? } })
2561
+ scheduleOrderNotification(payload: Record<string, unknown>) { return payload; }
2562
+ }
2563
+
2564
+ CLIENT-SIDE (browser — publishable key):
2565
+ Clients CAN produce messages using a publishable key + session token.
2566
+ Only produce to topics whose schema is safe for client authorship.
2567
+ NEVER allow clients to produce to topics that trigger privileged server-side operations
2568
+ (payments, admin actions, state mutations) — those must go through a backend endpoint first.
2569
+ import Ductape from '@ductape/sdk';
2570
+ const ductape = new Ductape({ publishableKey: 'pk_...', env: 'prd', product: 'my-product' });
2571
+ await ductape.events.produce({
2572
+ event: "user-events:user-action",
2573
+ message: { action: "button-click", screen: "dashboard" },
2574
+ session: "user-session:eyJ...", // REQUIRED for client-side produce
2575
+ });
2576
+
2577
+ SCHEDULED DISPATCH (background job):
2578
+ ductape_execute("messageBrokers.dispatch", [{
2579
+ product, env,
2580
+ broker: "order-events", // broker tag
2581
+ event: "order-events:reminder-due", // "broker:topic"
2582
+ input: { message: { orderId: "123" } },
2583
+ retries?: 3,
2584
+ session?: "session-tag:jwt",
2585
+ schedule?: {
2586
+ start_at?: 1735689600000, // Unix ms or ISO string
2587
+ cron?: "0 9 * * *", // recurring cron
2588
+ every?: 86400000, // recurring interval ms
2589
+ limit?: 10, // max repetitions
2590
+ endDate?: "2026-12-31",
2591
+ tz?: "America/New_York",
2592
+ },
2593
+ }])
2594
+ Returns: { job_id, status: "scheduled"|"queued", scheduled_at, recurring, next_run_at? }
2595
+
2596
+ ━━━ STEP 4: CONSUME — WRITTEN IN APPLICATION CODE ━━━
2597
+
2598
+ Consumers are auto-registered by the SDK on first consume call.
2599
+ Consumer registration options (all optional — used for tracking in Workbench):
2600
+ consumer?: { tag?: string, name?: string, description?: string }
2601
+ If tag is omitted, Ductape generates one: "consumer-<brokerTag>-<topicTag>".
2602
+
2603
+ ACK BEHAVIOR (automatic):
2604
+ - Callback returns successfully → message is acknowledged (ack)
2605
+ - Callback throws → message is tracked as failed; broker nacks/retries per provider behavior
2606
+ - After max retries → message moves to dead-letter queue (DLQ)
2607
+ There is no manual ack API. Acknowledgement is implicit from callback outcome.
2608
+
2609
+ CONSUMER GROUPS (Kafka-specific):
2610
+ Consumer groups are set in the broker's envs[].config.groupId (at broker registration time).
2611
+ All service instances sharing the same groupId form a consumer group and share partition load.
2612
+ To configure: set groupId in the kafka config when creating/updating the broker.
2613
+
2614
+ CONCURRENCY:
2615
+ Ductape has no per-consumer concurrency setting. Concurrency is determined by:
2616
+ - Number of running service instances (horizontal scale)
2617
+ - Broker-level partition count (Kafka) or visibility timeout (SQS)
2618
+ Run multiple instances of your service to scale consumption.
2619
+
2620
+ GENERAL BACKEND (TypeScript/Node.js — not NestJS):
2621
+ Start consuming in your module init or service startup:
2425
2622
  await ductape.events.consume({
2426
2623
  product: "my-product",
2427
2624
  env: "prd",
2428
- event: "broker_tag:topic_tag",
2429
- callback: async (message) => { /* handle message */ },
2625
+ event: "order-events:order-created",
2626
+ callback: async (message) => {
2627
+ // All real processing logic goes here.
2628
+ // Throw to nack. Return to ack.
2629
+ await processOrder(message as { orderId: string; total: number });
2630
+ },
2631
+ consumer?: { tag: "order-processor", name: "Order Processor" },
2430
2632
  });
2431
- Callback errors are re-thrown so the broker can nack/retry.
2432
2633
 
2433
- Background dispatch with scheduling write in your service/controller:
2434
- await ductape.events.dispatch({ product, env, broker, event, input: { message },
2435
- schedule?: { start_at?, cron?, every?, limit?, endDate?, tz? } })
2634
+ NESTJS use SDK in onModuleInit (no @Events.Consume decorator exists yet):
2635
+ @Injectable()
2636
+ export class OrderConsumerService implements OnModuleInit {
2637
+ constructor(private readonly ductape: Ductape) {}
2638
+ async onModuleInit() {
2639
+ await this.ductape.events.consume({
2640
+ product: "my-product",
2641
+ env: process.env.DUCTAPE_ENV || 'prd',
2642
+ event: "order-events:order-created",
2643
+ callback: async (message) => { await this.handle(message); },
2644
+ consumer: { tag: "order-consumer", name: "Order Consumer" },
2645
+ });
2646
+ }
2647
+ private async handle(message: unknown) { /* business logic */ }
2648
+ }
2436
2649
 
2437
- For the four standard producer declarations (match-state, match-report, projection-updated,
2438
- notification), write these produce calls in the relevant application service methods there is
2439
- no separate configuration file or CLI step. The SDK creates the producer metadata on first call.
2650
+ CLIENT-SIDE: Clients CANNOT consume. Event consumption is always server-side only.
2651
+ This is the key distinction between server topics (produce + consume) and client-observable
2652
+ topics (produce from client, consume on server). Never set up a consumer in browser code.
2440
2653
 
2441
- Message payload is AES-encrypted before the tracking API call — tracking never sees plaintext.
2654
+ DEAD-LETTER QUEUE (DLQ):
2655
+ Messages whose callbacks consistently throw are automatically moved to the DLQ.
2656
+ Query: ductape_execute("messageBrokers.messages.getDeadLetters",
2657
+ [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }])
2658
+ Reprocess: ductape_execute("messageBrokers.reprocessDLQ",
2659
+ [{ product, env, brokerTag, topicTag?, messageIds?, limit? }])
2660
+ Replay: ductape_execute("messageBrokers.replayEvent",
2661
+ [{ product, env, eventId, force? }])
2442
2662
 
2443
2663
  ━━━ OBSERVABILITY ━━━
2444
2664
 
2445
2665
  messageBrokers.messages.query [{ product, env, brokerTag, topicTag?, status?, page?, limit? }]
2446
2666
  messageBrokers.messages.getStats [{ product, env, brokerTag }]
2447
2667
  messageBrokers.messages.getDashboard [{ product, env, brokerTag }]
2448
- messageBrokers.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, limit? }]
2668
+ messageBrokers.messages.getDeadLetters [{ product, env, brokerTag, topicTag?, consumerTag?, limit? }]
2669
+ messageBrokers.messages.getProducers [{ product, env, brokerTag, topicTag?, page?, limit? }]
2670
+ messageBrokers.messages.getConsumers [{ product, env, brokerTag, topicTag?, page?, limit? }]
2449
2671
  messageBrokers.replayEvent [{ product, env, eventId, force? }]
2450
2672
  messageBrokers.reprocessDLQ [{ product, env, brokerTag, topicTag?, messageIds?, limit? }]
2451
2673
  messageBrokers.checkIdempotency [{ product, env, brokerTag, idempotency_key }]
@@ -3449,7 +3671,7 @@ async function main() {
3449
3671
  ' GCP Pub/Sub service identifier is "pubsub". AWS SQS is "sqs". Azure Service Bus is "servicebus".\n' +
3450
3672
  ' Message brokers are import-only (no provision-persist). Import flow is the same as storage.\n' +
3451
3673
  ' type field = "messageBrokers" (not "messagebrokers" or "events").\n' +
3452
- ' After importing, create producers topics are auto-created with the producer (except SQS, which needs explicit topics.create with queueUrls first).\n' +
3674
+ ' After importing, create topics first with ductape_cli("events topics create -f topic.json") — SQS requires explicit topic creation with queueUrls. For other providers, topics auto-register on first produce but should still be created explicitly before any consumer subscribes.\n' +
3453
3675
  ' - Listing workspaces, products, secrets\n' +
3454
3676
  ' - Linking a project folder: "link --product <tag> --env <slug>"\n' +
3455
3677
  ' - Syncing sessions/notifications/events: "apply" or "apply sessions" etc.\n' +