@alvera-ai/platform-sdk 0.21.1-next.ge834089 → 0.21.2

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.
@@ -104,6 +104,14 @@ END $$;
104
104
  The same applies to `down_statement`, which is likelier to be run against a
105
105
  database that is not in the state you assumed.
106
106
 
107
+ ### One statement per body
108
+
109
+ The platform runs each body as a single prepared statement. Two commands in one
110
+ `up_statement` are accepted at create and fail at migrate with Postgres `42601`
111
+ `cannot insert multiple commands into a prepared statement`. The two examples
112
+ above are therefore two migrations, not one body. A `DO $$ … $$` block counts as
113
+ one statement, so steps that must travel together go inside one.
114
+
107
115
  ### The table names are the platform's, not yours
108
116
 
109
117
  A generic table you declared as `contact_submissions` is physically
@@ -2826,46 +2826,51 @@ if (advancingPoller.status !== 'active') {
2826
2826
  }
2827
2827
  ```
2828
2828
 
2829
- ## 045 — an SES sender on its own endpoint, and SES's events reconciled
2829
+ ## 045 — an SES sender on its own endpoint, and its delivery reconciled
2830
2830
 
2831
2831
  The campaign's email went out through the mock provider. A real deployment
2832
- sends through SES, and two things about SES are worth proving rather than
2832
+ sends through SES, and three things about SES are worth proving rather than
2833
2833
  reading about.
2834
2834
 
2835
2835
  **An SES tool sends to its own `endpoint_url`.** It is the whole request URL;
2836
2836
  left blank, the tool uses `https://email.<region>.amazonaws.com`. Pointed at
2837
- a local AWS stand-in, every call goes there instead, which is how this walk
2838
- proves it without an AWS account. The stand-in at `localhost:4566` answers
2839
- SES and lists every message it received at `GET /_ministack/ses/messages`,
2840
- so a test call through the tool comes back with the id the stand-in gave it,
2841
- and the stand-in's list holds a message with that id. Nothing else could
2842
- have issued it. The stand-in serves SES only when `ses` is on its services
2843
- list; without it, every SES call answers 400 `Unsupported service: ses`.
2844
-
2845
- **SES has no CloudWatch Logs destination of its own.** Its events travel
2846
- configuration set → EventBridge → rule → log group, and EventBridge wraps
2847
- each SES event in `detail`. The platform's SES templates read that envelope:
2848
- `detail.mail.messageId` is the id SES returned to the send, the one the
2849
- message row carries as `external_id`. The stand-in does not publish SES
2850
- events, so the step writes three in that shape itself, under account
2851
- `000000000000`: a send and a delivery for the message the test call just
2852
- sent, and a bounce for another. Then it points an updater at that log
2853
- group.
2837
+ a local AWS stand-in, every send goes there instead, which is how this walk
2838
+ sends without an AWS account. A send that reached SES comes back with SES's
2839
+ message id, and the message row keeps it as `external_id`.
2840
+
2841
+ **A message row comes from a workflow, not from a test call.** A tool's
2842
+ `testInvocation` sends, but writes no message row, so there would be nothing
2843
+ to reconcile. The step sends one email to one recipient from a workflow
2844
+ whose only action uses the SES tool. The action fires after the run is
2845
+ recorded, so the step waits for the row. A user search keeps the rows that
2846
+ matched when it was created and never picks up a later one, so each try
2847
+ creates a new search.
2854
2848
 
2855
2849
  **Name a status template by its provider's path.** Mailgun and SES both ship
2856
2850
  an `email_message_status` under `status_poller`, so the bare name answers
2857
2851
  409 with both paths as candidates. The updater names
2858
2852
  `status_poller/aws_ses/email_message_status`.
2859
2853
 
2854
+ **Delivery comes from SES's events.** SES has no CloudWatch Logs destination
2855
+ of its own: its events travel configuration set → EventBridge → rule → log
2856
+ group, and the platform's SES template reads each one. `detail.mail.messageId`
2857
+ finds the message by its `external_id`; a Delivery moves it to `delivered`;
2858
+ a Bounce, Complaint or Reject moves it to `failed`; any other event leaves it
2859
+ where it is. The local stand-in keeps every email it was sent but publishes
2860
+ none of SES's events, so locally the poll finds nothing and the message stays
2861
+ `sent`. Against real SES, with the configuration set routed to the updater's
2862
+ log group, the same poll moves it to `delivered` at SES's own delivery time.
2863
+ The step accepts either, and nothing else.
2864
+
2860
2865
  **`refresh` only enqueues a poll.** It answers 202 with the row as it was;
2861
2866
  read the row again until `last_run_at` moves. `last_run_status` is `ok` when
2862
2867
  the run read its whole window and `error` when it stopped, never anything in
2863
- between, and `last_run_events_found` counts the events it read.
2868
+ between.
2864
2869
 
2865
2870
  ```typescript
2866
2871
  const SES_ENDPOINT = 'http://localhost:4566'
2867
2872
  const SES_LOG_GROUP = '/aws/events/cookbook-ses-delivery'
2868
- const SES_LOG_STREAM = 'cookbook-ses-walk'
2873
+ const SES_DECISION_KEY = 'ses_delivery_check'
2869
2874
 
2870
2875
  const sesTool = await ctx.ensure(
2871
2876
  'SES email sender',
@@ -2902,30 +2907,6 @@ if (storedSesBody.provider !== 'ses' || storedSesBody.endpoint_url !== SES_ENDPO
2902
2907
  throw new Error(`the SES tool must keep its endpoint, read back ${JSON.stringify(storedSesBody)}`)
2903
2908
  }
2904
2909
 
2905
- // A test call goes where the tool points, and the stand-in keeps what it received.
2906
- const { data: sesSend } = await api.tools.testInvocation(tenantSlug, datalakeSlug, ctx.sesToolId, {
2907
- tool_call: {
2908
- tool_call_type: 'email_request',
2909
- to: { type: 'custom', body: 'test-recipient@example.com' },
2910
- subject: { type: 'custom', body: 'SES endpoint check' },
2911
- body: { type: 'custom', body: 'test' },
2912
- link_prefix: ctx.campaignLinkPrefix,
2913
- },
2914
- })
2915
- if (sesSend.status === 'error') {
2916
- throw new Error(`the SES test call failed: ${sesSend.error_message}`)
2917
- }
2918
- const received = (await (await fetch(`${SES_ENDPOINT}/_ministack/ses/messages`)).json()) as {
2919
- messages: Record<string, { MessageId: string; Subject: string }[]>
2920
- }
2921
- const sentHere = Object.values(received.messages)
2922
- .flat()
2923
- .find((m) => m.Subject === 'SES endpoint check' && JSON.stringify(sesSend.result).includes(m.MessageId))
2924
- if (!sentHere) {
2925
- throw new Error(`the stand-in holds no message with the id the test call returned: ${JSON.stringify(sesSend.result)}`)
2926
- }
2927
- const sentMessageId = sentHere.MessageId
2928
-
2929
2910
  // The bare name is ambiguous between Mailgun and SES.
2930
2911
  let bareName: unknown
2931
2912
  try {
@@ -2940,74 +2921,91 @@ if (!JSON.stringify(bareName).includes('status_poller/aws_ses/email_message_stat
2940
2921
  throw new Error(`the 409 must name the SES path among its candidates: ${JSON.stringify(bareName)}`)
2941
2922
  }
2942
2923
 
2943
- // Three SES events as EventBridge delivers them to a log group. The stand-in reads the
2944
- // Authorization header for region and key but does not check the signature.
2945
- async function logs(target: string, payload: Record<string, unknown>): Promise<void> {
2946
- const resp = await fetch(SES_ENDPOINT, {
2947
- method: 'POST',
2948
- headers: {
2949
- 'Content-Type': 'application/x-amz-json-1.1',
2950
- 'X-Amz-Target': `Logs_20140328.${target}`,
2951
- Authorization:
2952
- 'AWS4-HMAC-SHA256 Credential=localkey/20260101/us-east-1/logs/aws4_request, ' +
2953
- 'SignedHeaders=content-type;host;x-amz-target, Signature=local',
2954
- },
2955
- body: JSON.stringify(payload),
2956
- })
2957
- if (resp.ok) return
2958
- const text = await resp.text()
2959
- if (text.includes('ResourceAlreadyExistsException')) return
2960
- throw new Error(`log group call ${target} failed (${resp.status}): ${text}`)
2961
- }
2962
-
2963
- const sentAt = Date.now() - 3_000
2964
- const sesEnvelope = (detailType: string, detail: Record<string, unknown>) => ({
2965
- version: '0',
2966
- id: crypto.randomUUID(),
2967
- 'detail-type': detailType,
2968
- source: 'aws.ses',
2969
- account: '000000000000',
2970
- time: new Date(sentAt).toISOString(),
2971
- region: 'us-east-1',
2972
- resources: ['arn:aws:ses:us-east-1:000000000000:configuration-set/cookbook-ses'],
2973
- detail,
2974
- })
2975
- const sesMail = (messageId: string) => ({
2976
- timestamp: new Date(sentAt).toISOString(),
2977
- source: 'campaigns@bloom-salon.example.com',
2978
- messageId,
2979
- destination: ['test-recipient@example.com'],
2980
- })
2981
- const sesEvents = [
2982
- sesEnvelope('Email Sent', { eventType: 'Send', mail: sesMail(sentMessageId), send: {} }),
2983
- sesEnvelope('Email Delivered', {
2984
- eventType: 'Delivery',
2985
- mail: sesMail(sentMessageId),
2986
- delivery: {
2987
- timestamp: new Date(sentAt + 1_000).toISOString(),
2988
- recipients: ['test-recipient@example.com'],
2989
- smtpResponse: '250 2.0.0 OK',
2990
- },
2991
- }),
2992
- sesEnvelope('Email Bounced', {
2993
- eventType: 'Bounce',
2994
- mail: sesMail('test-ses-bounced-000000'),
2995
- bounce: {
2996
- bounceType: 'Permanent',
2997
- bounceSubType: 'General',
2998
- bouncedRecipients: [
2999
- { emailAddress: 'test-bounce@example.com', status: '5.1.1', diagnosticCode: 'smtp; 550 5.1.1 user unknown' },
2924
+ // One email, to one recipient, through the SES tool.
2925
+ const sesCheck = await ctx.ensure(
2926
+ 'SES delivery check workflow',
2927
+ async () => {
2928
+ const { data } = await api.workflows.list(tenantSlug, datalakeSlug)
2929
+ return (data.data ?? []).find((w: { name?: string }) => w.name === 'Cookbook SES Delivery Check')
2930
+ },
2931
+ async () => {
2932
+ const { data } = await api.workflows.create(tenantSlug, datalakeSlug, {
2933
+ name: 'Cookbook SES Delivery Check',
2934
+ description: 'One email through the SES tool, so its delivery can be reconciled.',
2935
+ dataset_type: 'generic_table',
2936
+ generic_table_id: ctx.audienceTableId,
2937
+ status: 'live',
2938
+ tags: ['marketing', 'ses'],
2939
+ skip_mdm_resolution: false,
2940
+ filter_config: { type: 'custom', body: '{% if event_dataset.email and event_dataset.email != "" %}true{% endif %}' },
2941
+ decision_config: {
2942
+ type: 'custom',
2943
+ body: `["${SES_DECISION_KEY}"]`,
2944
+ output_schema: { type: 'array', items: { type: 'string' } },
2945
+ },
2946
+ actions: [
2947
+ {
2948
+ action_type: 'email',
2949
+ tool_id: ctx.sesToolId,
2950
+ decision_key: SES_DECISION_KEY,
2951
+ position: 0,
2952
+ trigger_template: 'now',
2953
+ idempotency_template: '{{ event_dataset.end_customer_id }}/ses-delivery-check',
2954
+ tool_call: {
2955
+ tool_call_type: 'email_request',
2956
+ to: { type: 'custom', body: '{{ event_dataset.email }}' },
2957
+ subject: { type: 'custom', body: 'Your slot at Bloom Salon' },
2958
+ body: {
2959
+ type: 'custom',
2960
+ body:
2961
+ `<p>Hi {{ event_dataset.name }}, we have kept your slot.</p>` +
2962
+ `<p><a href="${ctx.bookingPageUrl}">See your booking</a></p>`,
2963
+ },
2964
+ link_prefix: ctx.campaignLinkPrefix,
2965
+ },
2966
+ },
3000
2967
  ],
3001
- },
3002
- }),
3003
- ]
3004
- await logs('CreateLogGroup', { logGroupName: SES_LOG_GROUP })
3005
- await logs('CreateLogStream', { logGroupName: SES_LOG_GROUP, logStreamName: SES_LOG_STREAM })
3006
- await logs('PutLogEvents', {
3007
- logGroupName: SES_LOG_GROUP,
3008
- logStreamName: SES_LOG_STREAM,
3009
- logEvents: sesEvents.map((event, i) => ({ timestamp: sentAt + i * 1_000, message: JSON.stringify(event) })),
2968
+ })
2969
+ return data
2970
+ },
2971
+ )
2972
+ ctx.sesCheckWorkflowSlug = sesCheck.slug!
2973
+
2974
+ const { data: sesRun } = await api.workflows.run(tenantSlug, datalakeSlug, ctx.sesCheckWorkflowSlug, {
2975
+ sql_where_clause: `end_customer_id = '${ctx.adaId}'`,
2976
+ mode: 'live',
2977
+ manual_override: false,
3010
2978
  })
2979
+ await ctx.waitForFiredRun(datalakeSlug, sesRun.workflow_run_id)
2980
+
2981
+ // The message row, found by its idempotency key, which names the same send on every rerun.
2982
+ // A search keeps the rows that matched when it was created, so each try creates a new one.
2983
+ const sesSentDeadline = Date.now() + 45_000
2984
+ let sesMessage: { status?: string; external_id?: string | null; sender_tool_id?: string } | undefined
2985
+ while (Date.now() < sesSentDeadline) {
2986
+ const { data: sesSearch } = await api.datasets.createUserSearch(tenantSlug, datalakeSlug, 'message', {
2987
+ search_query: `m.idempotency_key = '${ctx.adaId}/ses-delivery-check'`,
2988
+ })
2989
+ if (sesSearch.status !== 'completed') {
2990
+ throw new Error(`message user-search status=${sesSearch.status} error=${sesSearch.error_message ?? '(none)'}`)
2991
+ }
2992
+ const { data } = await api.datasets.search(tenantSlug, datalakeSlug, 'message', {
2993
+ userSearchId: sesSearch.id!,
2994
+ dataAccessMode: 'raw',
2995
+ })
2996
+ sesMessage = (data.data ?? [])[0] as typeof sesMessage
2997
+ if (sesMessage?.external_id) break
2998
+ await new Promise((r) => setTimeout(r, 1_000))
2999
+ }
3000
+ if (!sesMessage?.external_id) {
3001
+ throw new Error(`the SES send must leave a message carrying SES's id, got ${JSON.stringify(sesMessage)}`)
3002
+ }
3003
+ if (sesMessage.sender_tool_id !== ctx.sesToolId) {
3004
+ throw new Error(`the message must name the SES tool as its sender, got ${sesMessage.sender_tool_id}`)
3005
+ }
3006
+ if (!['sent', 'delivered'].includes(sesMessage.status ?? '')) {
3007
+ throw new Error(`the SES send must leave the message sent, got ${sesMessage.status}`)
3008
+ }
3011
3009
 
3012
3010
  const sesUpdater = await ctx.ensure(
3013
3011
  'SES delivery reconciler',
@@ -3051,8 +3049,22 @@ if (polled.last_run_at === beforePoll.last_run_at) {
3051
3049
  if (polled.last_run_status !== 'ok') {
3052
3050
  throw new Error(`the SES poll must read its whole window, got ${polled.last_run_status}: ${polled.last_run_error}`)
3053
3051
  }
3054
- if ((polled.last_run_events_found ?? 0) < sesEvents.length) {
3055
- throw new Error(`the SES poll must find the ${sesEvents.length} events, found ${polled.last_run_events_found}`)
3052
+
3053
+ // The message as the poll left it: delivered where SES published a Delivery, still sent where nothing did.
3054
+ const sesExternalId = sesMessage.external_id
3055
+ const { data: sesPage } = await api.datalakes.executeSql(tenantSlug, datalakeSlug, {
3056
+ sql: `SELECT status, external_id FROM messages WHERE sender_tool_id = '${ctx.sesToolId}' AND channel = 'email'`,
3057
+ mode: 'raw',
3058
+ })
3059
+ if (typeof sesPage === 'string') {
3060
+ throw new Error('executeSql answered CSV where it should answer rows')
3061
+ }
3062
+ const sesAfter = (sesPage.data as unknown[][]).find((row) => row[1] === sesExternalId)
3063
+ if (!sesAfter) {
3064
+ throw new Error(`no message with SES's id ${sesExternalId} after the poll: ${JSON.stringify(sesPage.data)}`)
3065
+ }
3066
+ if (!['sent', 'delivered'].includes(String(sesAfter[0]))) {
3067
+ throw new Error(`after the poll the message must be sent or delivered, got ${sesAfter[0]}`)
3056
3068
  }
3057
3069
  ```
3058
3070
 
@@ -3360,8 +3372,9 @@ datalake**:
3360
3372
  provider delivery events into updates on a schedule, its stored body is
3361
3373
  read back against its checksum, and a poller whose request could never move
3362
3374
  to the next page is refused at create (§042–§044). An SES sender sends to
3363
- its own endpoint, and an updater reads SES's delivery events through the
3364
- platform's SES template (§045).
3375
+ its own endpoint, a workflow's email through it leaves a message carrying
3376
+ SES's id, and an updater reconciles that message through the platform's SES
3377
+ template (§045).
3365
3378
  - **The lake answers in plain language.** Text-to-SQL proposes a query
3366
3379
  without running it; the read-only executor runs one and returns the same
3367
3380
  page as CSV (§046–§047).
@@ -335,7 +335,11 @@ definition must name a unique combination, and a table is sometimes
335
335
  unique only together with its id — say `(organization_id, id)`.
336
336
  Declaring `id` with `is_unique: true` puts it in that combination and
337
337
  adds no column (every table already has one). Declared any other way,
338
- `id` is a reserved name like the rest.
338
+ `id` is a reserved name like the rest. And it may join the combination,
339
+ never be the whole of it: create accepts a table whose only unique
340
+ column is `id`, but the column sync drops reserved names before it
341
+ reads the unique columns, finds none, and fails every migrate of the
342
+ lake, which stays at `new`. Mark a column of the table's own unique.
339
343
 
340
344
  **`legal_entity_id` is the one that catches people.** It is not a
341
345
  column you declare and fill — a contract's `mdm_input_config`
@@ -14,9 +14,11 @@ third-party APIs the platform integrates with — Athenahealth, Twilio, Mailgun,
14
14
  Stripe, SendGrid, HubSpot, QuickBooks, Airtable, Atomic FI, the LLM providers,
15
15
  and a small set of AWS endpoints MiniStack cannot serve.
16
16
 
17
- **MiniStack** serves AWS itself: `s3,lambda,sns,ssm,sqs,sts,logs,secretsmanager,cloudformation`
17
+ **MiniStack** serves AWS itself: `s3,lambda,sns,ssm,sqs,sts,logs,secretsmanager,cloudformation,rds,ses`
18
18
  (`local-dependencies.yml`). See [Datalakes](datalakes.md) for how datalake
19
- provisioning uses it.
19
+ provisioning uses it. An SES email tool whose endpoint URL is `http://localhost:4566`
20
+ sends into MiniStack, which lists every message it received at
21
+ `http://localhost:4566/_ministack/ses/messages`.
20
22
 
21
23
  The division is not arbitrary. No AWS emulator here serves
22
24
  `pinpoint-sms-voice-v2` — LocalStack Community answered 501 (Ultimate tier
package/dist/index.d.mts CHANGED
@@ -1804,10 +1804,10 @@ type GenericTableListResponse = {
1804
1804
  /**
1805
1805
  * MMSCallResponse
1806
1806
  *
1807
- * MMS tool-call config — Liquid-templated recipient and body, plain public media URL, and the prefix its links go out on.
1807
+ * MMS tool-call config — Liquid-templated recipient, optional caption, plain public media URL, and the prefix its links go out on.
1808
1808
  */
1809
1809
  type MmsCallResponse = {
1810
- body: SimpleTemplateConfigResponse;
1810
+ body?: SimpleTemplateConfigResponse;
1811
1811
  /**
1812
1812
  * Address of the host that serves this action's links. Every URL in the body goes out as <link_prefix>/<token>. An absolute http or https URL, path allowed, with no query, fragment or trailing slash.
1813
1813
  */
@@ -4777,10 +4777,10 @@ type InteroperabilityContractAiAgentRequestWritable = {
4777
4777
  /**
4778
4778
  * MMSCallRequest
4779
4779
  *
4780
- * MMS tool-call config — Liquid-templated recipient and body, plain public media URL, and the prefix its links go out on. Request
4780
+ * MMS tool-call config — Liquid-templated recipient, optional caption, plain public media URL, and the prefix its links go out on. Request
4781
4781
  */
4782
4782
  type MmsCallRequestWritable = {
4783
- body: SimpleTemplateConfigRequest;
4783
+ body?: SimpleTemplateConfigRequest;
4784
4784
  /**
4785
4785
  * Address of the host that serves this action's links. Every URL in the body goes out as <link_prefix>/<token>. An absolute http or https URL, path allowed, with no query, fragment or trailing slash.
4786
4786
  */