@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.
- package/.agent/advanced_migrations.md +8 -0
- package/.agent/cookbook/organic-marketing.md +128 -115
- package/.agent/generic_tables.md +5 -1
- package/.agent/mock-services.md +4 -2
- package/dist/index.d.mts +4 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +7 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
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
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
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
|
-
|
|
3055
|
-
|
|
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,
|
|
3364
|
-
platform's SES
|
|
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).
|
package/.agent/generic_tables.md
CHANGED
|
@@ -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`
|
package/.agent/mock-services.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
*/
|