@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 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: BooksLineInput[];
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 bindsSchedule(binding) {
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 => bindsSchedule(property.type === 'RestElement' ? property.argument : property.value));
13
- case 'ArrayPattern': return binding.elements.some(element => element !== null && bindsSchedule(element));
14
- case 'AssignmentPattern': return bindsSchedule(binding.left);
15
- case 'RestElement': return bindsSchedule(binding.argument);
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) === 'schedule')
62
- fail('export one literal schedule directly with export const schedule.');
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 === 'schedule' : specifier.exported.value === 'schedule'))
66
- fail('export one literal schedule directly with export const schedule.');
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 === 'schedule')
69
- fail('export one literal schedule directly with export const schedule.');
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 (!bindsSchedule(declaration.id))
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 schedule.');
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
- jobs.push({ name, ...(schedule ? { schedule } : {}) });
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
- if (this.options.queueUrl) {
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isomorph.ai/app-sdk",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",