@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.
- package/dist/index.js +291 -69
- package/package.json +1 -1
- 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
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
←
|
|
472
|
-
|
|
473
|
-
←
|
|
474
|
-
|
|
475
|
-
messageBrokers.topics.
|
|
476
|
-
|
|
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
|
|
1778
|
-
Use ductape_cli for all product-level operations
|
|
1779
|
-
|
|
1780
|
-
|
|
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
|
|
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:
|
|
2419
|
+
━━━ STEP 2: DEFINE TOPICS ━━━
|
|
2302
2420
|
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
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
|
-
|
|
2308
|
-
|
|
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_execute — to create topics.
|
|
2310
2427
|
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
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
|
-
|
|
2327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
(event name, existing data models, user input), present it to the user for approval, then implement.
|
|
2462
|
+
GENERAL BACKEND (TypeScript/Node.js — not 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: "
|
|
2346
|
-
message: { key: value },
|
|
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
|
|
2349
|
-
|
|
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
|
-
|
|
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: "
|
|
2356
|
-
callback: async (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
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
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
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
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
|
-
|
|
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
|
|
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
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
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
←
|
|
483
|
-
|
|
484
|
-
←
|
|
485
|
-
|
|
486
|
-
messageBrokers.topics.
|
|
487
|
-
|
|
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
|
|
1844
|
-
Use ductape_cli for all product-level operations
|
|
1845
|
-
|
|
1846
|
-
|
|
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
|
|
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:
|
|
2492
|
+
━━━ STEP 2: DEFINE TOPICS ━━━
|
|
2375
2493
|
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
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
|
-
|
|
2381
|
-
|
|
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_execute — to create topics.
|
|
2383
2500
|
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
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
|
-
|
|
2400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
(event name, existing data models, user input), present it to the user for approval, then implement.
|
|
2535
|
+
GENERAL BACKEND (TypeScript/Node.js — not 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: "
|
|
2419
|
-
message: { key: value },
|
|
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
|
-
|
|
2552
|
+
NESTJS — method 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: "
|
|
2429
|
-
callback: async (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
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
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
|
-
|
|
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
|
|
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' +
|