@isomorph.ai/app-sdk 1.31.0 → 1.33.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 +50 -3
- package/dist/index.d.ts +24 -0
- package/dist/index.js +39 -0
- package/dist/jobs.d.ts +13 -2
- package/dist/jobs.js +16 -2
- package/dist/lambda-events-entry.js +5 -3
- package/dist/runtime.d.ts +11 -8
- package/dist/runtime.js +74 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -473,6 +473,32 @@ Without `onProgress` the call is unchanged and `ctx.progress` does nothing. At
|
|
|
473
473
|
most 256 steps of up to 1 KiB each reach the page; further steps are dropped.
|
|
474
474
|
The action still has the 300 s action budget. (Unreleased)
|
|
475
475
|
|
|
476
|
+
## Watching a query: `data.watch`
|
|
477
|
+
|
|
478
|
+
Progress, status and export screens re-read one query on a timer:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
const stop = isomorph.data.watch(
|
|
482
|
+
() => isomorph.data.from('imports').select('*').eq('id', importId).single(),
|
|
483
|
+
({ data }) => setImport(data),
|
|
484
|
+
{ every: 3000, maxEvery: 15000 }, // the defaults
|
|
485
|
+
);
|
|
486
|
+
// on unmount
|
|
487
|
+
stop();
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`build()` runs at once and then every `every` ms (3 s). While the tab is
|
|
491
|
+
hidden no read is made (where there is no `document`, as in Node, nothing is
|
|
492
|
+
skipped). While the answer is unchanged the interval doubles up to `maxEvery`
|
|
493
|
+
(15 s) and goes back to `every` on the first change; every result, changed or
|
|
494
|
+
not, reaches `onResult`. A read that fails (a 401, the network) reaches it
|
|
495
|
+
too, as `{ data: null, error }` with the error `await`ing the query would
|
|
496
|
+
have thrown, and the timer keeps running. There is no new transport or route: each tick is an ordinary
|
|
497
|
+
`QueryBuilder.execute()`, so it counts as app activity and is billed and
|
|
498
|
+
authorized like any read. Watch the job row, not the data table. Use
|
|
499
|
+
`realtime.channel` only when several people edit the same rows and sub-second
|
|
500
|
+
updates matter. (1.32.0)
|
|
501
|
+
|
|
476
502
|
## Running on Lambda
|
|
477
503
|
|
|
478
504
|
On a serverless cell Harbour runs an app's server code as Lambda functions, from a `runtime.zip` Kit Build makes; nothing in the app changes. The Web Function starts `dist/lambda-entry.js` (the zip's `index.js` is `import "@isomorph.ai/app-sdk/dist/lambda-entry.js"`), which serves actions on the port in `AWS_LAMBDA_HTTP_ENDPOINT` and stops on SIGTERM. The Event Function's handler is `node_modules/@isomorph.ai/app-sdk/dist/lambda-events-entry.handler`: `{"job": "<name>"}` runs that job once, as `--run-job <name>` does, and SQS records work queued runs until none are left.
|
|
@@ -485,7 +511,7 @@ On a serverless cell Harbour runs an app's server code as Lambda functions, from
|
|
|
485
511
|
|
|
486
512
|
### Email-arrival jobs
|
|
487
513
|
|
|
488
|
-
On managed serverless hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
|
514
|
+
On managed same-account serverless or Kubernetes hosting, an ordinary job can declare an inbox trigger instead of a schedule:
|
|
489
515
|
|
|
490
516
|
```ts
|
|
491
517
|
export const trigger = {
|
|
@@ -506,8 +532,29 @@ may additionally require an exact sender. Deployment registers the job; activati
|
|
|
506
532
|
the app owner's approved read access and background consent for that environment.
|
|
507
533
|
Multiple apps share one provider watch per mailbox. Notifications enqueue the existing durable
|
|
508
534
|
job runtime; duplicate deliveries retain the same run instead of rerunning a completed job.
|
|
509
|
-
This does not add a per-app polling timer.
|
|
510
|
-
supported by this first release. Local manual job execution tests the handler, not the provider
|
|
535
|
+
This does not add a per-app polling timer. Cross-account delivery is not supported. Local manual job execution tests the handler, not the provider
|
|
511
536
|
subscription; verify a real incoming email after deployment before calling the trigger live.
|
|
512
537
|
|
|
513
538
|
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.
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
### Folder-arrival jobs
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
export const trigger = {
|
|
545
|
+
type: 'file.created', connection: 'company-drive', resource: 'my-drive',
|
|
546
|
+
folderId: 'provider-folder-id', mimeTypes: ['application/pdf'],
|
|
547
|
+
};
|
|
548
|
+
export default async (ctx) => {
|
|
549
|
+
// ctx.input is FileArrivalInput: provider/sourceId/fileId/folderId/driveId?,
|
|
550
|
+
// name/mimeType/size/createdAt/observedAt. Read bytes through the existing integration.
|
|
551
|
+
};
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
SharePoint uses its configured site alias as `resource` and also requires `driveId`.
|
|
555
|
+
The trigger covers newly created direct-child PDF/XLSX files observed after activation;
|
|
556
|
+
omit `mimeTypes` for both formats. Existing files moved into the folder, edits, renames,
|
|
557
|
+
subfolders, shortcuts and native Google documents are excluded. No historical backfill.
|
|
558
|
+
Background read consent is required. Existing file-read size limits still apply. Provider
|
|
559
|
+
subscriptions and real upload acceptance must be verified after deployment. Retries retain
|
|
560
|
+
one durable job run; accounting/email side effects still need their own idempotency keys.
|
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,15 @@ export interface IsomorphResult<T> {
|
|
|
39
39
|
data: T;
|
|
40
40
|
count?: number;
|
|
41
41
|
}
|
|
42
|
+
/** `data.watch` timing: `every` ms between reads (default 3000), backing off to `maxEvery` (default 15000) while the answer does not change. */
|
|
43
|
+
export interface WatchOptions {
|
|
44
|
+
every?: number;
|
|
45
|
+
maxEvery?: number;
|
|
46
|
+
}
|
|
47
|
+
/** What `data.watch` delivers: the read's result, or `{ data: null, error }` when the read failed (the error `await`ing the query would have thrown). */
|
|
48
|
+
export type WatchResult = IsomorphResult<any> & {
|
|
49
|
+
error?: Error;
|
|
50
|
+
};
|
|
42
51
|
/** How Slack renders a message's `text`: plain text, or Slack mrkdwn (`*bold*`, `<url|label>`, `<@U123>`); plain when absent. */
|
|
43
52
|
export type SlackTextFormat = 'plain' | 'mrkdwn';
|
|
44
53
|
/**
|
|
@@ -1895,6 +1904,7 @@ export declare function createClient(options?: ClientOptions): {
|
|
|
1895
1904
|
data: {
|
|
1896
1905
|
from: <T = Record<string, unknown>>(table: string) => QueryBuilder<T, T[]>;
|
|
1897
1906
|
rpc: <T = unknown>(name: string, args?: Record<string, unknown>) => Promise<T>;
|
|
1907
|
+
watch: <T = Record<string, unknown>>(build: () => QueryBuilder<T, any>, onResult: (result: WatchResult) => void, options?: WatchOptions) => (() => void);
|
|
1898
1908
|
};
|
|
1899
1909
|
files: {
|
|
1900
1910
|
createUpload: (input: {
|
|
@@ -1962,3 +1972,17 @@ export type HarbourResult<T> = IsomorphResult<T>;
|
|
|
1962
1972
|
export type HarbourClient = IsomorphClient;
|
|
1963
1973
|
/** @deprecated Internal transformation lane only; a kit app uses IsomorphCompatSource. */
|
|
1964
1974
|
export type HarbourCompatSource = import('./supabase-compat.js').IsomorphCompatSource;
|
|
1975
|
+
/** Provider file metadata delivered to a file.created job; bytes are read through its connection. */
|
|
1976
|
+
export interface FileArrivalInput {
|
|
1977
|
+
type: 'file.created';
|
|
1978
|
+
provider: 'drive' | 'sharepoint';
|
|
1979
|
+
sourceId: string;
|
|
1980
|
+
fileId: string;
|
|
1981
|
+
folderId: string;
|
|
1982
|
+
driveId?: string;
|
|
1983
|
+
name: string;
|
|
1984
|
+
mimeType: string;
|
|
1985
|
+
size: number;
|
|
1986
|
+
createdAt: string;
|
|
1987
|
+
observedAt: string;
|
|
1988
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -707,6 +707,45 @@ export function createClient(options = {}) {
|
|
|
707
707
|
data: {
|
|
708
708
|
from: (table) => new QueryBuilder(transport, table),
|
|
709
709
|
rpc: (name, args = {}) => transport.json(`/_harbour/data/rpc/${encodeURIComponent(name)}`, { method: 'POST', body: JSON.stringify(args) }),
|
|
710
|
+
// A query on a timer: QueryBuilder.execute() now and every `every` ms,
|
|
711
|
+
// no read while the tab is hidden, the interval doubling up to
|
|
712
|
+
// `maxEvery` while the answer is unchanged. No new transport or route;
|
|
713
|
+
// each read is an ordinary read (it counts as app activity), so watch
|
|
714
|
+
// the job row, not the data table. A failed read is delivered as
|
|
715
|
+
// { data: null, error } and the timer keeps going.
|
|
716
|
+
watch: (build, onResult, options = {}) => {
|
|
717
|
+
const every = options.every ?? 3_000;
|
|
718
|
+
const maxEvery = options.maxEvery ?? 15_000;
|
|
719
|
+
let delay = every;
|
|
720
|
+
let previous;
|
|
721
|
+
let stopped = false;
|
|
722
|
+
let timer;
|
|
723
|
+
const schedule = () => { if (!stopped)
|
|
724
|
+
timer = setTimer(tick, delay); };
|
|
725
|
+
const tick = async () => {
|
|
726
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
727
|
+
schedule();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
let result;
|
|
731
|
+
try {
|
|
732
|
+
result = await build().execute();
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
result = { data: null, error: error instanceof Error ? error : new Error(String(error)) };
|
|
736
|
+
}
|
|
737
|
+
if (stopped)
|
|
738
|
+
return;
|
|
739
|
+
const json = JSON.stringify({ ...result, error: result.error?.message });
|
|
740
|
+
delay = json === previous ? Math.min(delay * 2, maxEvery) : every;
|
|
741
|
+
previous = json;
|
|
742
|
+
onResult(result);
|
|
743
|
+
schedule();
|
|
744
|
+
};
|
|
745
|
+
void tick();
|
|
746
|
+
return () => { stopped = true; if (timer !== undefined)
|
|
747
|
+
clearTimer(timer); };
|
|
748
|
+
},
|
|
710
749
|
},
|
|
711
750
|
files: {
|
|
712
751
|
createUpload: (input) => transport.json('/_harbour/files/uploads', { method: 'POST', body: JSON.stringify(input) }),
|
package/dist/jobs.d.ts
CHANGED
|
@@ -8,12 +8,23 @@ export interface EmailArrivalTrigger {
|
|
|
8
8
|
subjectContains?: string;
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
|
+
export declare const FILE_MIME_TYPES: readonly ["application/pdf", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"];
|
|
12
|
+
export declare const providerFileId: (value: unknown) => value is string;
|
|
13
|
+
export interface FileCreatedTrigger {
|
|
14
|
+
type: 'file.created';
|
|
15
|
+
connection: string;
|
|
16
|
+
resource: string;
|
|
17
|
+
folderId: string;
|
|
18
|
+
driveId?: string;
|
|
19
|
+
mimeTypes?: string[];
|
|
20
|
+
}
|
|
21
|
+
export type EventTrigger = EmailArrivalTrigger | FileCreatedTrigger;
|
|
11
22
|
export interface JobDefinition {
|
|
12
23
|
name: string;
|
|
13
24
|
schedule?: string;
|
|
14
|
-
trigger?:
|
|
25
|
+
trigger?: EventTrigger;
|
|
15
26
|
}
|
|
16
|
-
export declare function
|
|
27
|
+
export declare function eventTrigger(value: unknown): EventTrigger;
|
|
17
28
|
export interface JobContext {
|
|
18
29
|
scheduledAt: string;
|
|
19
30
|
runId?: string;
|
package/dist/jobs.js
CHANGED
|
@@ -6,10 +6,14 @@ 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
|
+
export const FILE_MIME_TYPES = ['application/pdf', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'];
|
|
10
|
+
export const providerFileId = (value) => typeof value === 'string' && /^[A-Za-z0-9!._,=-]{1,256}$/.test(value);
|
|
9
11
|
function literalObject(node) {
|
|
10
12
|
const value = node;
|
|
11
13
|
if (value?.type === 'Literal')
|
|
12
14
|
return value.value;
|
|
15
|
+
if (value?.type === 'ArrayExpression')
|
|
16
|
+
return value.elements.map(literalObject);
|
|
13
17
|
if (value?.type !== 'ObjectExpression')
|
|
14
18
|
throw new Error('trigger must be a literal object');
|
|
15
19
|
const result = {};
|
|
@@ -23,7 +27,17 @@ function literalObject(node) {
|
|
|
23
27
|
}
|
|
24
28
|
return result;
|
|
25
29
|
}
|
|
26
|
-
export function
|
|
30
|
+
export function eventTrigger(value) {
|
|
31
|
+
if (value?.type === 'file.created') {
|
|
32
|
+
const t = value;
|
|
33
|
+
if (Object.keys(t).some(k => !['type', 'connection', 'resource', 'folderId', 'driveId', 'mimeTypes'].includes(k))
|
|
34
|
+
|| typeof t.connection !== 'string' || !LOGICAL_NAME.test(t.connection)
|
|
35
|
+
|| typeof t.resource !== 'string' || !t.resource.trim() || t.resource.length > 320
|
|
36
|
+
|| !providerFileId(t.folderId) || (t.driveId !== undefined && !providerFileId(t.driveId))
|
|
37
|
+
|| (t.mimeTypes !== undefined && (!Array.isArray(t.mimeTypes) || !t.mimeTypes.length || t.mimeTypes.length > 2 || new Set(t.mimeTypes).size !== t.mimeTypes.length || t.mimeTypes.some(m => !FILE_MIME_TYPES.includes(m)))))
|
|
38
|
+
throw new Error('file.created requires a connection, resource, folder ID and optional PDF/XLSX MIME types');
|
|
39
|
+
return t;
|
|
40
|
+
}
|
|
27
41
|
const trigger = value;
|
|
28
42
|
if (!trigger || typeof trigger !== 'object' || Object.keys(trigger).some(key => !['type', 'connection', 'resource', 'filter'].includes(key))
|
|
29
43
|
|| trigger.type !== 'email.arrived' || trigger.resource !== 'inbox' || typeof trigger.connection !== 'string' || !LOGICAL_NAME.test(trigger.connection))
|
|
@@ -102,7 +116,7 @@ export async function discoverJobs(root) {
|
|
|
102
116
|
if (statement.declaration.kind !== 'const')
|
|
103
117
|
fail('export one literal trigger with export const trigger.');
|
|
104
118
|
try {
|
|
105
|
-
trigger =
|
|
119
|
+
trigger = eventTrigger(literalObject(declaration.init));
|
|
106
120
|
}
|
|
107
121
|
catch (error) {
|
|
108
122
|
fail(String(error));
|
|
@@ -18,9 +18,11 @@ function eventRuntime() {
|
|
|
18
18
|
return runtime;
|
|
19
19
|
}
|
|
20
20
|
export async function handler(event) {
|
|
21
|
-
const { job, Records: records, mailArrival } = (event && typeof event === 'object' ? event : {});
|
|
22
|
-
if (mailArrival !== undefined)
|
|
23
|
-
|
|
21
|
+
const { job, Records: records, mailArrival, eventArrival } = (event && typeof event === 'object' ? event : {});
|
|
22
|
+
if (eventArrival !== undefined && mailArrival !== undefined)
|
|
23
|
+
throw new Error('ambiguous arrival envelope');
|
|
24
|
+
if (eventArrival !== undefined || mailArrival !== undefined)
|
|
25
|
+
return eventRuntime().acceptEventArrival(eventArrival ?? mailArrival);
|
|
24
26
|
if (typeof job === 'string' && job) {
|
|
25
27
|
if (!(await eventRuntime().runJobOnce(job)))
|
|
26
28
|
throw new Error(`jobs/${job}.ts did not run to completion; the log above names why`);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export declare class Runs {
|
|
|
50
50
|
enqueue(job: string, input: unknown, owner: string): Promise<{
|
|
51
51
|
id: string;
|
|
52
52
|
}>;
|
|
53
|
-
|
|
53
|
+
enqueueEvent(delivery: {
|
|
54
54
|
deliveryId: string;
|
|
55
55
|
job: string;
|
|
56
56
|
input: unknown;
|
|
@@ -61,7 +61,7 @@ export declare class Runs {
|
|
|
61
61
|
get(id: string, owner: string): Promise<JobRun | null>;
|
|
62
62
|
cancel(id: string, owner: string): Promise<JobRun | null>;
|
|
63
63
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
64
|
-
reclaim(now: Date): Promise<number>;
|
|
64
|
+
reclaim(now: Date, active?: ReadonlySet<string>): Promise<number>;
|
|
65
65
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
66
66
|
claim(now: Date): Promise<JobRun | null>;
|
|
67
67
|
progress(id: string, percent: number, note?: string): Promise<void>;
|
|
@@ -106,8 +106,10 @@ export declare class Runtime {
|
|
|
106
106
|
private readonly timers;
|
|
107
107
|
private stopped;
|
|
108
108
|
private aiBinding;
|
|
109
|
-
private
|
|
109
|
+
private queueWorkers;
|
|
110
110
|
private queueWake;
|
|
111
|
+
private claimTail;
|
|
112
|
+
private readonly activeRuns;
|
|
111
113
|
private readonly runs;
|
|
112
114
|
private session;
|
|
113
115
|
private readonly log;
|
|
@@ -127,8 +129,8 @@ export declare class Runtime {
|
|
|
127
129
|
*/
|
|
128
130
|
drainQueue(): Promise<void>;
|
|
129
131
|
/**
|
|
130
|
-
* Starts
|
|
131
|
-
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
132
|
+
* Starts one free execution slot, up to the fixed local bound. Its first
|
|
133
|
+
* pass runs at once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
132
134
|
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
133
135
|
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
134
136
|
* timer armed, so an idle runtime makes no data calls at all.
|
|
@@ -141,7 +143,7 @@ export declare class Runtime {
|
|
|
141
143
|
* would have been. False when the app has no such job, the gateway's data
|
|
142
144
|
* path never answered, or the handler threw; the log names which.
|
|
143
145
|
*/
|
|
144
|
-
|
|
146
|
+
acceptEventArrival(value: unknown): Promise<void>;
|
|
145
147
|
runJobOnce(name: string, now?: Date): Promise<boolean>;
|
|
146
148
|
private awaitDataPath;
|
|
147
149
|
stop(): Promise<void>;
|
|
@@ -152,10 +154,11 @@ export declare class Runtime {
|
|
|
152
154
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
|
153
155
|
private runScheduled;
|
|
154
156
|
/**
|
|
155
|
-
* One pass
|
|
157
|
+
* One worker pass: reclaim interrupted leases, claim one run, then execute
|
|
158
|
+
* outside the claim lock so another slot can handle unrelated work.
|
|
156
159
|
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
157
160
|
* run it finished left a retry queued — so the loop should look again; false
|
|
158
|
-
* when the queue is empty
|
|
161
|
+
* when the queue is empty or both execution slots are occupied.
|
|
159
162
|
*/
|
|
160
163
|
work(): Promise<boolean>;
|
|
161
164
|
private serve;
|
package/dist/runtime.js
CHANGED
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
// database calls: the runtime is the process that serves the enqueue action
|
|
21
21
|
// and the one that finishes every run, so it knows when work exists. It runs
|
|
22
22
|
// one pass at start (reclaiming whatever a crashed predecessor left running
|
|
23
|
-
// and draining any backlog),
|
|
23
|
+
// and draining any backlog), filling at most two local execution slots. Each
|
|
24
|
+
// slot loops every QUEUE_POLL_MS only while a pass
|
|
24
25
|
// found something — a claimed run, a reclaimed lease, a run that finished with
|
|
25
26
|
// retries queued — and stops with no timer armed once the queue is empty. An
|
|
26
27
|
// enqueue starts the loop again. This holds because kit-runtime is pinned to
|
|
@@ -67,7 +68,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
67
68
|
import { createClient, gatewayRequest, IsomorphError } from './index.js';
|
|
68
69
|
import { userGroupsFromHeader } from './user-groups.js';
|
|
69
70
|
import { userRole } from './user-role.js';
|
|
70
|
-
import { discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
|
|
71
|
+
import { FILE_MIME_TYPES, providerFileId, discoverJobs, exportsTrue, invokeJob, loadHandler, JOBS_DIR } from './jobs.js';
|
|
71
72
|
import { LocalGateway } from './local-gateway.js';
|
|
72
73
|
export { JOBS_DIR } from './jobs.js';
|
|
73
74
|
const actionIdentity = new AsyncLocalStorage();
|
|
@@ -83,6 +84,7 @@ export const CANCEL_RUN_ACTION = 'isomorph-jobs-cancel';
|
|
|
83
84
|
const LOGICAL_NAME = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
84
85
|
const MAX_BODY_BYTES = 1 << 20;
|
|
85
86
|
const LEASE_MS = 5 * 60_000;
|
|
87
|
+
const QUEUE_WORKERS = 2;
|
|
86
88
|
const QUEUE_POLL_MS = 2_000;
|
|
87
89
|
const TOKEN_REFRESH_MS = 60_000;
|
|
88
90
|
// A gateway session lasts an hour. An action runs at most 300 s, so a request
|
|
@@ -204,7 +206,7 @@ export class Runs {
|
|
|
204
206
|
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();
|
|
205
207
|
return { id: data.id };
|
|
206
208
|
}
|
|
207
|
-
async
|
|
209
|
+
async enqueueEvent(delivery) {
|
|
208
210
|
const hex = createHash('sha256').update(`mail:${delivery.deliveryId}`).digest('hex');
|
|
209
211
|
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
212
|
try {
|
|
@@ -230,13 +232,14 @@ export class Runs {
|
|
|
230
232
|
return data;
|
|
231
233
|
}
|
|
232
234
|
/** Rows whose lease ran out go back to the queue while attempts remain, else fail; how many there were. */
|
|
233
|
-
async reclaim(now) {
|
|
235
|
+
async reclaim(now, active = new Set()) {
|
|
234
236
|
const { data: expired } = await this.table().select('*').eq('status', 'running').lt('lease_until', now.toISOString()).limit(20);
|
|
235
|
-
|
|
237
|
+
const interrupted = expired.filter(run => !active.has(run.id));
|
|
238
|
+
for (const run of interrupted) {
|
|
236
239
|
const exhausted = run.attempt >= run.max_attempts;
|
|
237
240
|
await this.table().update({ status: exhausted ? 'failed' : 'queued', error: exhausted ? 'the worker was interrupted and no attempts remain' : run.error, lease_until: null, updated_at: now.toISOString() }).eq('id', run.id).eq('status', 'running');
|
|
238
241
|
}
|
|
239
|
-
return
|
|
242
|
+
return interrupted.length;
|
|
240
243
|
}
|
|
241
244
|
/** Claims the oldest queued run by a conditional update; null when the queue is empty or another worker won. */
|
|
242
245
|
async claim(now) {
|
|
@@ -270,10 +273,10 @@ export class Runtime {
|
|
|
270
273
|
timers = [];
|
|
271
274
|
stopped = false;
|
|
272
275
|
aiBinding;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
276
|
+
queueWorkers = 0;
|
|
277
|
+
queueWake = 0;
|
|
278
|
+
claimTail = Promise.resolve();
|
|
279
|
+
activeRuns = new Set();
|
|
277
280
|
runs;
|
|
278
281
|
// Built on first use: the AWS signing modules load only on a serverless
|
|
279
282
|
// cell, so a cluster runtime never imports them.
|
|
@@ -339,8 +342,8 @@ export class Runtime {
|
|
|
339
342
|
await new Promise((wake) => setTimeout(wake, this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
340
343
|
}
|
|
341
344
|
/**
|
|
342
|
-
* Starts
|
|
343
|
-
* once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
345
|
+
* Starts one free execution slot, up to the fixed local bound. Its first
|
|
346
|
+
* pass runs at once; each further pass follows QUEUE_POLL_MS after one that found work
|
|
344
347
|
* (or threw: a gateway that is waking answers 5xx, and the row that woke
|
|
345
348
|
* the loop is still there). A pass that finds nothing ends the loop with no
|
|
346
349
|
* timer armed, so an idle runtime makes no data calls at all.
|
|
@@ -348,17 +351,16 @@ export class Runtime {
|
|
|
348
351
|
wakeQueue() {
|
|
349
352
|
if (this.stopped || !this.worksQueue())
|
|
350
353
|
return;
|
|
351
|
-
|
|
352
|
-
|
|
354
|
+
this.queueWake += 1;
|
|
355
|
+
if (this.queueWorkers >= QUEUE_WORKERS)
|
|
353
356
|
return;
|
|
354
|
-
|
|
355
|
-
this.queueLive = true;
|
|
357
|
+
this.queueWorkers += 1;
|
|
356
358
|
const pass = async () => {
|
|
357
359
|
if (this.stopped) {
|
|
358
|
-
this.
|
|
360
|
+
this.queueWorkers -= 1;
|
|
359
361
|
return;
|
|
360
362
|
}
|
|
361
|
-
this.queueWake
|
|
363
|
+
const wake = this.queueWake;
|
|
362
364
|
let again = true;
|
|
363
365
|
try {
|
|
364
366
|
again = await this.work();
|
|
@@ -366,14 +368,10 @@ export class Runtime {
|
|
|
366
368
|
catch (error) {
|
|
367
369
|
this.log(`queue pass failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
368
370
|
}
|
|
369
|
-
if (this.stopped)
|
|
370
|
-
this.queueLive = false;
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (again || this.queueWake)
|
|
371
|
+
if (!this.stopped && (again || wake !== this.queueWake))
|
|
374
372
|
this.timers.push(setTimeout(() => void pass(), this.options.queuePollMs ?? QUEUE_POLL_MS));
|
|
375
373
|
else
|
|
376
|
-
this.
|
|
374
|
+
this.queueWorkers -= 1;
|
|
377
375
|
};
|
|
378
376
|
void pass();
|
|
379
377
|
}
|
|
@@ -384,23 +382,36 @@ export class Runtime {
|
|
|
384
382
|
* would have been. False when the app has no such job, the gateway's data
|
|
385
383
|
* path never answered, or the handler threw; the log names which.
|
|
386
384
|
*/
|
|
387
|
-
async
|
|
385
|
+
async acceptEventArrival(value) {
|
|
388
386
|
const delivery = value;
|
|
389
387
|
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
|
-
|
|
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');
|
|
388
|
+
|| typeof delivery.owner !== 'string' || !delivery.owner || delivery.owner.length > 320 || !delivery.input || typeof delivery.input !== 'object')
|
|
389
|
+
throw new Error('invalid event arrival');
|
|
396
390
|
this.inventory = await discover(this.options.root);
|
|
397
391
|
const job = this.inventory.jobs.find(job => job.name === delivery.job);
|
|
398
|
-
|
|
399
|
-
|
|
392
|
+
const input = delivery.input;
|
|
393
|
+
if (!job?.trigger || job.trigger.connection !== input.sourceId)
|
|
394
|
+
throw new Error('event trigger is not declared by this deployed app');
|
|
395
|
+
if (job.trigger.type === 'file.created') {
|
|
396
|
+
const instant = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v) && Number.isFinite(Date.parse(v));
|
|
397
|
+
if (Object.keys(input).some(k => !['type', 'provider', 'sourceId', 'fileId', 'folderId', 'driveId', 'name', 'mimeType', 'size', 'createdAt', 'observedAt'].includes(k))
|
|
398
|
+
|| input.type !== 'file.created' || !['drive', 'sharepoint'].includes(String(input.provider)) || !providerFileId(input.fileId) || input.folderId !== job.trigger.folderId
|
|
399
|
+
|| (input.driveId !== undefined && !providerFileId(input.driveId)) || (input.provider === 'sharepoint' && !input.driveId)
|
|
400
|
+
|| (job.trigger.driveId !== undefined && input.driveId !== job.trigger.driveId)
|
|
401
|
+
|| typeof input.name !== 'string' || !input.name || input.name.length > 1024 || !Number.isSafeInteger(input.size) || Number(input.size) < 0
|
|
402
|
+
|| !(job.trigger.mimeTypes ?? [...FILE_MIME_TYPES]).includes(String(input.mimeType))
|
|
403
|
+
|| !instant(input.createdAt) || !instant(input.observedAt))
|
|
404
|
+
throw new Error('invalid file arrival');
|
|
405
|
+
}
|
|
406
|
+
else if (input.type !== undefined || !['gmail', 'outlook'].includes(String(input.provider))
|
|
407
|
+
|| typeof input.messageId !== 'string' || !input.messageId || input.messageId.length > 2048
|
|
408
|
+
|| typeof input.threadId !== 'string' || input.threadId.length > 2048
|
|
409
|
+
|| typeof input.receivedAt !== 'string' || !Number.isFinite(Date.parse(input.receivedAt)))
|
|
410
|
+
throw new Error('invalid mail arrival');
|
|
400
411
|
await this.refreshToken(JOB_SESSION_MIN_VALIDITY_MS);
|
|
401
412
|
if (!this.options.gatewayUrl || this.options.mode === 'gate' || this.options.jobRuns === false || !(await this.awaitDataPath()))
|
|
402
|
-
throw new Error('
|
|
403
|
-
const queued = await this.runs.
|
|
413
|
+
throw new Error('event arrival requires durable job runs');
|
|
414
|
+
const queued = await this.runs.enqueueEvent(delivery);
|
|
404
415
|
await this.signalQueuedRun(queued.id);
|
|
405
416
|
}
|
|
406
417
|
async runJobOnce(name, now = new Date()) {
|
|
@@ -539,21 +550,38 @@ export class Runtime {
|
|
|
539
550
|
}
|
|
540
551
|
}
|
|
541
552
|
/**
|
|
542
|
-
* One pass
|
|
553
|
+
* One worker pass: reclaim interrupted leases, claim one run, then execute
|
|
554
|
+
* outside the claim lock so another slot can handle unrelated work.
|
|
543
555
|
* True when the pass found work — a lease reclaimed, a run claimed, or the
|
|
544
556
|
* run it finished left a retry queued — so the loop should look again; false
|
|
545
|
-
* when the queue is empty
|
|
557
|
+
* when the queue is empty or both execution slots are occupied.
|
|
546
558
|
*/
|
|
547
559
|
async work() {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
this.
|
|
560
|
+
// Serialize only the claim, not the handler. A second slot must not race
|
|
561
|
+
// the same oldest row or reclaim a long handler still running here.
|
|
562
|
+
const previous = this.claimTail;
|
|
563
|
+
let release;
|
|
564
|
+
this.claimTail = new Promise(resolve => { release = resolve; });
|
|
565
|
+
await previous;
|
|
566
|
+
let run;
|
|
567
|
+
let reclaimed = 0;
|
|
568
|
+
try {
|
|
569
|
+
if (this.activeRuns.size >= QUEUE_WORKERS)
|
|
570
|
+
return false;
|
|
571
|
+
const now = new Date();
|
|
572
|
+
reclaimed = await this.runs.reclaim(now, this.activeRuns);
|
|
573
|
+
run = await this.runs.claim(now);
|
|
574
|
+
if (run)
|
|
575
|
+
this.activeRuns.add(run.id);
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
release();
|
|
579
|
+
}
|
|
580
|
+
if (!run)
|
|
581
|
+
return reclaimed > 0;
|
|
582
|
+
this.wakeQueue(); // Fill the spare slot when startup discovers a backlog.
|
|
551
583
|
try {
|
|
552
584
|
const now = new Date();
|
|
553
|
-
const reclaimed = await this.runs.reclaim(now);
|
|
554
|
-
const run = await this.runs.claim(now);
|
|
555
|
-
if (!run)
|
|
556
|
-
return reclaimed > 0;
|
|
557
585
|
if (!this.inventory.jobs.some((job) => job.name === run.job)) {
|
|
558
586
|
await this.runs.finish({ ...run, attempt: run.max_attempts }, { error: `no jobs/${run.job}.ts in this app` });
|
|
559
587
|
return true;
|
|
@@ -574,7 +602,7 @@ export class Runtime {
|
|
|
574
602
|
return true;
|
|
575
603
|
}
|
|
576
604
|
finally {
|
|
577
|
-
this.
|
|
605
|
+
this.activeRuns.delete(run.id);
|
|
578
606
|
}
|
|
579
607
|
}
|
|
580
608
|
async serve(request, response) {
|
|
@@ -645,7 +673,7 @@ export class Runtime {
|
|
|
645
673
|
if (name === 'isomorph-mail-arrival') {
|
|
646
674
|
if (header(request, 'X-Harbour-Verified-Mail-Arrival') !== 'true')
|
|
647
675
|
return refuse(response, 403, 'FORBIDDEN', 'governed mail delivery is required', requestId);
|
|
648
|
-
await this.
|
|
676
|
+
await this.acceptEventArrival(input);
|
|
649
677
|
return answer(response, 200, { data: { accepted: true } }, requestId);
|
|
650
678
|
}
|
|
651
679
|
if (name.startsWith(RESERVED_PREFIX))
|