@isomorph.ai/app-sdk 1.27.0 → 1.29.0
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/README.md +29 -0
- package/dist/index.d.ts +6 -1
- package/dist/integration-execute-input-rules.d.ts +7 -0
- package/dist/integration-execute-input-rules.js +1 -0
- package/dist/jobs.d.ts +11 -0
- package/dist/jobs.js +56 -15
- package/dist/lambda-events-entry.js +3 -1
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.js +42 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -482,3 +482,32 @@ On a serverless cell Harbour runs an app's server code as Lambda functions, from
|
|
|
482
482
|
`ISOMORPH_JOBS_QUEUE_URL`: `isomorph.jobs.enqueue` writes the run, then sends one message to this SQS queue; the runtime never works the queue itself.
|
|
483
483
|
|
|
484
484
|
(1.24.0; data plane ADR 0065)
|
|
485
|
+
|
|
486
|
+
### Email-arrival jobs
|
|
487
|
+
|
|
488
|
+
On managed serverless hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
export const trigger = {
|
|
492
|
+
type: 'email.arrived',
|
|
493
|
+
connection: 'company-gmail',
|
|
494
|
+
resource: 'inbox',
|
|
495
|
+
filter: { subjectContains: 'Invoice' },
|
|
496
|
+
};
|
|
497
|
+
export default async (ctx) => {
|
|
498
|
+
// ctx.input: provider, sourceId, messageId, threadId, receivedAt.
|
|
499
|
+
// Read the message/attachments through the declared integration operations.
|
|
500
|
+
// Persist business progress in the app's own workflow records.
|
|
501
|
+
};
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`connection` names the configured Gmail or Outlook connection. A literal `filter.from`
|
|
505
|
+
may additionally require an exact sender. Deployment registers the job; activation requires
|
|
506
|
+
the app owner's approved read access and background consent for that environment.
|
|
507
|
+
Multiple apps share one provider watch per mailbox. Notifications enqueue the existing durable
|
|
508
|
+
job runtime; duplicate deliveries retain the same run instead of rerunning a completed job.
|
|
509
|
+
This does not add a per-app polling timer. Cluster hosting and cross-account delivery are not
|
|
510
|
+
supported by this first release. Local manual job execution tests the handler, not the provider
|
|
511
|
+
subscription; verify a real incoming email after deployment before calling the trigger live.
|
|
512
|
+
|
|
513
|
+
For PO-linked Zoho bills, first read the PO and send each returned `lineItemId` as the bill line’s `purchaseOrderItemId`. `itemId` identifies the catalog item; it does not identify the PO line. Purchase-order list results are summaries; read the document before comparing lines.
|
package/dist/index.d.ts
CHANGED
|
@@ -1100,6 +1100,9 @@ export interface BooksLineInput {
|
|
|
1100
1100
|
taxId?: string;
|
|
1101
1101
|
description?: string;
|
|
1102
1102
|
}
|
|
1103
|
+
export interface BooksBillLineInput extends BooksLineInput {
|
|
1104
|
+
purchaseOrderItemId?: string;
|
|
1105
|
+
}
|
|
1103
1106
|
export interface BooksDocument {
|
|
1104
1107
|
id: string;
|
|
1105
1108
|
number: string;
|
|
@@ -1110,6 +1113,8 @@ export interface BooksDocument {
|
|
|
1110
1113
|
customerId?: string;
|
|
1111
1114
|
lineItems: {
|
|
1112
1115
|
itemId: string;
|
|
1116
|
+
lineItemId?: string;
|
|
1117
|
+
purchaseOrderItemId?: string;
|
|
1113
1118
|
quantity: string;
|
|
1114
1119
|
rate: string;
|
|
1115
1120
|
total: string;
|
|
@@ -1151,7 +1156,7 @@ export type ZohoBooksRequest = {
|
|
|
1151
1156
|
documentNumber: string;
|
|
1152
1157
|
documentDate: string;
|
|
1153
1158
|
purchaseOrderId?: string;
|
|
1154
|
-
lineItems:
|
|
1159
|
+
lineItems: BooksBillLineInput[];
|
|
1155
1160
|
};
|
|
1156
1161
|
} | {
|
|
1157
1162
|
operation: 'zohobooks.invoice.create';
|
|
@@ -2268,6 +2268,13 @@ export declare const IntegrationExecuteInputRules: {
|
|
|
2268
2268
|
readonly minLength: 0;
|
|
2269
2269
|
readonly maxLength: 2000;
|
|
2270
2270
|
};
|
|
2271
|
+
readonly purchaseOrderItemId: {
|
|
2272
|
+
readonly kind: "string";
|
|
2273
|
+
readonly required: false;
|
|
2274
|
+
readonly minLength: 0;
|
|
2275
|
+
readonly maxLength: 32;
|
|
2276
|
+
readonly pattern: "^[0-9]{1,32}$";
|
|
2277
|
+
};
|
|
2271
2278
|
};
|
|
2272
2279
|
};
|
|
2273
2280
|
};
|
|
@@ -701,6 +701,7 @@ export const IntegrationExecuteInputRules = {
|
|
|
701
701
|
rate: { kind: "string", required: true, minLength: 0, maxLength: 17, pattern: "^[0-9]{1,10}(\\.[0-9]{1,6})?$" },
|
|
702
702
|
taxId: { kind: "string", required: false, minLength: 0, maxLength: 32, pattern: "^[0-9]{1,32}$" },
|
|
703
703
|
description: { kind: "string", required: false, minLength: 0, maxLength: 2000 },
|
|
704
|
+
purchaseOrderItemId: { kind: "string", required: false, minLength: 0, maxLength: 32, pattern: "^[0-9]{1,32}$" },
|
|
704
705
|
} },
|
|
705
706
|
},
|
|
706
707
|
},
|
package/dist/jobs.d.ts
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
export declare const JOBS_DIR = "jobs";
|
|
2
|
+
export interface EmailArrivalTrigger {
|
|
3
|
+
type: 'email.arrived';
|
|
4
|
+
connection: string;
|
|
5
|
+
resource: 'inbox';
|
|
6
|
+
filter?: {
|
|
7
|
+
from?: string;
|
|
8
|
+
subjectContains?: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
2
11
|
export interface JobDefinition {
|
|
3
12
|
name: string;
|
|
4
13
|
schedule?: string;
|
|
14
|
+
trigger?: EmailArrivalTrigger;
|
|
5
15
|
}
|
|
16
|
+
export declare function emailArrivalTrigger(value: unknown): EmailArrivalTrigger;
|
|
6
17
|
export interface JobContext {
|
|
7
18
|
scheduledAt: string;
|
|
8
19
|
runId?: string;
|
package/dist/jobs.js
CHANGED
|
@@ -6,13 +6,40 @@ import { stripTypeScriptTypes } from 'node:module';
|
|
|
6
6
|
import { parse } from 'acorn';
|
|
7
7
|
export const JOBS_DIR = 'jobs';
|
|
8
8
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
9
|
-
function
|
|
9
|
+
function literalObject(node) {
|
|
10
|
+
const value = node;
|
|
11
|
+
if (value?.type === 'Literal')
|
|
12
|
+
return value.value;
|
|
13
|
+
if (value?.type !== 'ObjectExpression')
|
|
14
|
+
throw new Error('trigger must be a literal object');
|
|
15
|
+
const result = {};
|
|
16
|
+
for (const property of value.properties ?? []) {
|
|
17
|
+
if (property.type !== 'Property' || property.computed || property.kind !== 'init' || property.method)
|
|
18
|
+
throw new Error('trigger must be a literal object');
|
|
19
|
+
const key = property.key.name ?? property.key.value;
|
|
20
|
+
if (typeof key !== 'string' || Object.hasOwn(result, key))
|
|
21
|
+
throw new Error('trigger contains an invalid or repeated field');
|
|
22
|
+
Object.defineProperty(result, key, { value: literalObject(property.value), enumerable: true });
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
export function emailArrivalTrigger(value) {
|
|
27
|
+
const trigger = value;
|
|
28
|
+
if (!trigger || typeof trigger !== 'object' || Object.keys(trigger).some(key => !['type', 'connection', 'resource', 'filter'].includes(key))
|
|
29
|
+
|| trigger.type !== 'email.arrived' || trigger.resource !== 'inbox' || typeof trigger.connection !== 'string' || !LOGICAL_NAME.test(trigger.connection))
|
|
30
|
+
throw new Error('trigger must declare email.arrived, a connection and resource inbox');
|
|
31
|
+
if (trigger.filter !== undefined && (!trigger.filter || typeof trigger.filter !== 'object' || Array.isArray(trigger.filter)
|
|
32
|
+
|| Object.entries(trigger.filter).some(([key, value]) => !['from', 'subjectContains'].includes(key) || typeof value !== 'string' || !value.trim() || value.length > 320)))
|
|
33
|
+
throw new Error('trigger filter supports non-empty from and subjectContains strings up to 320 characters');
|
|
34
|
+
return trigger;
|
|
35
|
+
}
|
|
36
|
+
function bindsJobSetup(binding) {
|
|
10
37
|
switch (binding.type) {
|
|
11
|
-
case 'Identifier': return binding.name === 'schedule';
|
|
12
|
-
case 'ObjectPattern': return binding.properties.some(property =>
|
|
13
|
-
case 'ArrayPattern': return binding.elements.some(element => element !== null &&
|
|
14
|
-
case 'AssignmentPattern': return
|
|
15
|
-
case 'RestElement': return
|
|
38
|
+
case 'Identifier': return binding.name === 'schedule' || binding.name === 'trigger';
|
|
39
|
+
case 'ObjectPattern': return binding.properties.some(property => bindsJobSetup(property.type === 'RestElement' ? property.argument : property.value));
|
|
40
|
+
case 'ArrayPattern': return binding.elements.some(element => element !== null && bindsJobSetup(element));
|
|
41
|
+
case 'AssignmentPattern': return bindsJobSetup(binding.left);
|
|
42
|
+
case 'RestElement': return bindsJobSetup(binding.argument);
|
|
16
43
|
default: return false;
|
|
17
44
|
}
|
|
18
45
|
}
|
|
@@ -57,23 +84,35 @@ export async function discoverJobs(root) {
|
|
|
57
84
|
fail(`cannot parse the job module: ${error instanceof Error ? error.message : String(error)}`);
|
|
58
85
|
}
|
|
59
86
|
let schedule;
|
|
87
|
+
let trigger;
|
|
60
88
|
for (const statement of statements) {
|
|
61
|
-
if (statement.type === 'ExportAllDeclaration' && statement.exported && (statement.exported.type === 'Identifier' ? statement.exported.name : statement.exported.value)
|
|
62
|
-
fail('export one literal schedule directly with export const
|
|
89
|
+
if (statement.type === 'ExportAllDeclaration' && statement.exported && ['schedule', 'trigger'].includes(statement.exported.type === 'Identifier' ? statement.exported.name : String(statement.exported.value)))
|
|
90
|
+
fail('export one literal schedule or trigger directly with export const.');
|
|
63
91
|
if (statement.type !== 'ExportNamedDeclaration')
|
|
64
92
|
continue;
|
|
65
|
-
if (statement.specifiers.some(specifier => specifier.exported.type === 'Identifier' ? specifier.exported.name
|
|
66
|
-
fail('export one literal schedule directly with export const
|
|
93
|
+
if (statement.specifiers.some(specifier => ['schedule', 'trigger'].includes(specifier.exported.type === 'Identifier' ? specifier.exported.name : String(specifier.exported.value))))
|
|
94
|
+
fail('export one literal schedule or trigger directly with export const.');
|
|
67
95
|
if (statement.declaration?.type !== 'VariableDeclaration') {
|
|
68
|
-
if (statement.declaration?.id?.name
|
|
69
|
-
fail('export one literal schedule directly with export const
|
|
96
|
+
if (['schedule', 'trigger'].includes(statement.declaration?.id?.name ?? ''))
|
|
97
|
+
fail('export one literal schedule or trigger directly with export const.');
|
|
70
98
|
continue;
|
|
71
99
|
}
|
|
72
100
|
for (const declaration of statement.declaration.declarations) {
|
|
73
|
-
if (
|
|
101
|
+
if (declaration.id.type === 'Identifier' && declaration.id.name === 'trigger') {
|
|
102
|
+
if (statement.declaration.kind !== 'const')
|
|
103
|
+
fail('export one literal trigger with export const trigger.');
|
|
104
|
+
try {
|
|
105
|
+
trigger = emailArrivalTrigger(literalObject(declaration.init));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
fail(String(error));
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!bindsJobSetup(declaration.id))
|
|
74
113
|
continue;
|
|
75
114
|
if (declaration.id.type !== 'Identifier')
|
|
76
|
-
fail('export one literal schedule directly with export const
|
|
115
|
+
fail('export one literal schedule or trigger directly with export const.');
|
|
77
116
|
const value = declaration.init;
|
|
78
117
|
if (statement.declaration.kind !== 'const')
|
|
79
118
|
fail('export one literal schedule.');
|
|
@@ -89,7 +128,9 @@ export async function discoverJobs(root) {
|
|
|
89
128
|
}
|
|
90
129
|
if (!statements.some(statement => statement.type === 'ExportDefaultDeclaration'))
|
|
91
130
|
fail('export one default job handler.');
|
|
92
|
-
|
|
131
|
+
if (schedule && trigger)
|
|
132
|
+
fail('a job may declare a schedule or an email trigger, not both.');
|
|
133
|
+
jobs.push({ name, ...(schedule ? { schedule } : {}), ...(trigger ? { trigger } : {}) });
|
|
93
134
|
}
|
|
94
135
|
return jobs.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
95
136
|
}
|
|
@@ -18,7 +18,9 @@ function eventRuntime() {
|
|
|
18
18
|
return runtime;
|
|
19
19
|
}
|
|
20
20
|
export async function handler(event) {
|
|
21
|
-
const { job, Records: records } = (event && typeof event === 'object' ? event : {});
|
|
21
|
+
const { job, Records: records, mailArrival } = (event && typeof event === 'object' ? event : {});
|
|
22
|
+
if (mailArrival !== undefined)
|
|
23
|
+
return eventRuntime().acceptMailArrival(mailArrival);
|
|
22
24
|
if (typeof job === 'string' && job) {
|
|
23
25
|
if (!(await eventRuntime().runJobOnce(job)))
|
|
24
26
|
throw new Error(`jobs/${job}.ts did not run to completion; the log above names why`);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -50,6 +50,14 @@ export declare class Runs {
|
|
|
50
50
|
enqueue(job: string, input: unknown, owner: string): Promise<{
|
|
51
51
|
id: string;
|
|
52
52
|
}>;
|
|
53
|
+
enqueueMail(delivery: {
|
|
54
|
+
deliveryId: string;
|
|
55
|
+
job: string;
|
|
56
|
+
input: unknown;
|
|
57
|
+
owner: string;
|
|
58
|
+
}): Promise<{
|
|
59
|
+
id: string;
|
|
60
|
+
}>;
|
|
53
61
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
54
62
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
55
63
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
@@ -133,6 +141,7 @@ export declare class Runtime {
|
|
|
133
141
|
* would have been. False when the app has no such job, the gateway's data
|
|
134
142
|
* path never answered, or the handler threw; the log names which.
|
|
135
143
|
*/
|
|
144
|
+
acceptMailArrival(value: unknown): Promise<void>;
|
|
136
145
|
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
137
146
|
private awaitDataPath;
|
|
138
147
|
stop(): Promise<void>;
|
|
@@ -152,6 +161,7 @@ export declare class Runtime {
|
|
|
152
161
|
private serve;
|
|
153
162
|
private transaction;
|
|
154
163
|
private loadFailures;
|
|
164
|
+
private signalQueuedRun;
|
|
155
165
|
private reserved;
|
|
156
166
|
}
|
|
157
167
|
/** The runtime's settings from the environment; exported for the entry point and its test. */
|
package/dist/runtime.js
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
// ISOMORPH_JOBS_QUEUE_URL an enqueue sends the run to this SQS queue and
|
|
57
57
|
// the queue loop never runs here: the cell's
|
|
58
58
|
// Event Function works it (lambda-events-entry.ts)
|
|
59
|
+
import { createHash } from 'node:crypto';
|
|
59
60
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
60
61
|
import { createServer } from 'node:http';
|
|
61
62
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
@@ -203,6 +204,19 @@ export class Runs {
|
|
|
203
204
|
const { data } = await this.table().insert({ job, input: input ?? null, status: 'queued', attempt: 0, max_attempts: DEFAULT_MAX_ATTEMPTS, progress: 0, owner_subject: owner, cancel_requested: false }).select('id').single();
|
|
204
205
|
return { id: data.id };
|
|
205
206
|
}
|
|
207
|
+
async enqueueMail(delivery) {
|
|
208
|
+
const hex = createHash('sha256').update(`mail:${delivery.deliveryId}`).digest('hex');
|
|
209
|
+
const id = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
210
|
+
try {
|
|
211
|
+
await this.table().insert({ id, job: delivery.job, input: delivery.input, status: 'queued', attempt: 0, max_attempts: DEFAULT_MAX_ATTEMPTS, progress: 0, owner_subject: delivery.owner, cancel_requested: false });
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
const existing = await this.get(id, delivery.owner);
|
|
215
|
+
if (!existing || existing.job !== delivery.job)
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
return { id };
|
|
219
|
+
}
|
|
206
220
|
async get(id, owner) {
|
|
207
221
|
const { data } = await this.table().select('*').eq('id', id).eq('owner_subject', owner).maybeSingle();
|
|
208
222
|
return data;
|
|
@@ -370,6 +384,25 @@ export class Runtime {
|
|
|
370
384
|
* would have been. False when the app has no such job, the gateway's data
|
|
371
385
|
* path never answered, or the handler threw; the log names which.
|
|
372
386
|
*/
|
|
387
|
+
async acceptMailArrival(value) {
|
|
388
|
+
const delivery = value;
|
|
389
|
+
if (!delivery || typeof delivery.deliveryId !== 'string' || !/^[a-f0-9]{64}$/.test(delivery.deliveryId)
|
|
390
|
+
|| typeof delivery.owner !== 'string' || !delivery.owner || delivery.owner.length > 320
|
|
391
|
+
|| !delivery.input || !['gmail', 'outlook'].includes(delivery.input.provider)
|
|
392
|
+
|| typeof delivery.input.messageId !== 'string' || !delivery.input.messageId || delivery.input.messageId.length > 2048
|
|
393
|
+
|| typeof delivery.input.threadId !== 'string' || delivery.input.threadId.length > 2048
|
|
394
|
+
|| typeof delivery.input.receivedAt !== 'string' || !Number.isFinite(Date.parse(delivery.input.receivedAt)))
|
|
395
|
+
throw new Error('invalid mail arrival');
|
|
396
|
+
this.inventory = await discover(this.options.root);
|
|
397
|
+
const job = this.inventory.jobs.find(job => job.name === delivery.job);
|
|
398
|
+
if (!job?.trigger || job.trigger.connection !== delivery.input.sourceId)
|
|
399
|
+
throw new Error('mail trigger is not declared by this deployed app');
|
|
400
|
+
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
401
|
+
if (!this.options.gatewayUrl || !this.options.queueUrl || this.options.jobRuns === false || !(await this.awaitDataPath()))
|
|
402
|
+
throw new Error('mail arrival requires durable job runs');
|
|
403
|
+
const queued = await this.runs.enqueueMail(delivery);
|
|
404
|
+
await this.signalQueuedRun(queued.id);
|
|
405
|
+
}
|
|
373
406
|
async runJobOnce(name, now = new Date()) {
|
|
374
407
|
this.inventory = await discover(this.options.root);
|
|
375
408
|
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
@@ -674,6 +707,14 @@ export class Runtime {
|
|
|
674
707
|
}
|
|
675
708
|
return failures;
|
|
676
709
|
}
|
|
710
|
+
async signalQueuedRun(id) {
|
|
711
|
+
if (this.options.queueUrl) {
|
|
712
|
+
const { awsEnvironment, sendQueueMessage } = await import('./aws.js');
|
|
713
|
+
await sendQueueMessage(awsEnvironment(), this.options.queueUrl, JSON.stringify({ runId: id }));
|
|
714
|
+
}
|
|
715
|
+
else
|
|
716
|
+
this.wakeQueue();
|
|
717
|
+
}
|
|
677
718
|
async reserved(name, input, context) {
|
|
678
719
|
const body = (input && typeof input === 'object' ? input : {});
|
|
679
720
|
const owner = context.user.id;
|
|
@@ -690,12 +731,7 @@ export class Runtime {
|
|
|
690
731
|
throw new IsomorphError('AUTH_REQUIRED', 'a run is started by a signed-in person', undefined, undefined, 401);
|
|
691
732
|
const queued = await this.runs.enqueue(job, body.input, owner);
|
|
692
733
|
// Awaited before the enqueue answers: a Web Function may be frozen as soon as it has replied.
|
|
693
|
-
|
|
694
|
-
const { awsEnvironment, sendQueueMessage } = await import('./aws.js');
|
|
695
|
-
await sendQueueMessage(awsEnvironment(), this.options.queueUrl, JSON.stringify({ runId: queued.id }));
|
|
696
|
-
}
|
|
697
|
-
else
|
|
698
|
-
this.wakeQueue();
|
|
734
|
+
await this.signalQueuedRun(queued.id);
|
|
699
735
|
return queued;
|
|
700
736
|
}
|
|
701
737
|
case GET_RUN_ACTION: {
|