@aglyn/plugins-workflows 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +52 -0
- package/package.json +53 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +19 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/automation-step-fields.component.d.ts +74 -0
- package/src/lib/components/automation-step-fields.component.js +1210 -0
- package/src/lib/components/automation-step-fields.component.js.map +1 -0
- package/src/lib/components/host-actions-card.component.d.ts +15 -0
- package/src/lib/components/host-actions-card.component.js +1229 -0
- package/src/lib/components/host-actions-card.component.js.map +1 -0
- package/src/lib/components/host-activity-card.component.d.ts +84 -0
- package/src/lib/components/host-activity-card.component.js +280 -0
- package/src/lib/components/host-activity-card.component.js.map +1 -0
- package/src/lib/components/host-run-history-card.component.d.ts +40 -0
- package/src/lib/components/host-run-history-card.component.js +284 -0
- package/src/lib/components/host-run-history-card.component.js.map +1 -0
- package/src/lib/components/host-webhooks-card.component.d.ts +15 -0
- package/src/lib/components/host-webhooks-card.component.js +421 -0
- package/src/lib/components/host-webhooks-card.component.js.map +1 -0
- package/src/lib/components/host-workflows-card.component.d.ts +18 -0
- package/src/lib/components/host-workflows-card.component.js +952 -0
- package/src/lib/components/host-workflows-card.component.js.map +1 -0
- package/src/lib/components/run-quota-line.component.d.ts +27 -0
- package/src/lib/components/run-quota-line.component.js +72 -0
- package/src/lib/components/run-quota-line.component.js.map +1 -0
- package/src/lib/components/use-automation-step-pickers.d.ts +27 -0
- package/src/lib/components/use-automation-step-pickers.js +205 -0
- package/src/lib/components/use-automation-step-pickers.js.map +1 -0
- package/src/lib/components/workflow-zones.d.ts +36 -0
- package/src/lib/components/workflow-zones.js +19 -0
- package/src/lib/components/workflow-zones.js.map +1 -0
- package/src/lib/components/workflows-console-page.d.ts +25 -0
- package/src/lib/components/workflows-console-page.js +112 -0
- package/src/lib/components/workflows-console-page.js.map +1 -0
- package/src/lib/components/workflows-console-sections.d.ts +34 -0
- package/src/lib/components/workflows-console-sections.js +50 -0
- package/src/lib/components/workflows-console-sections.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/declarations.server.d.ts +41 -0
- package/src/lib/declarations.server.js +54 -0
- package/src/lib/declarations.server.js.map +1 -0
- package/src/lib/engine/crm-action-steps.d.ts +129 -0
- package/src/lib/engine/crm-action-steps.js +432 -0
- package/src/lib/engine/crm-action-steps.js.map +1 -0
- package/src/lib/engine/flow-enrollments.d.ts +264 -0
- package/src/lib/engine/flow-enrollments.js +386 -0
- package/src/lib/engine/flow-enrollments.js.map +1 -0
- package/src/lib/engine/run-event-actions.d.ts +232 -0
- package/src/lib/engine/run-event-actions.js +1392 -0
- package/src/lib/engine/run-event-actions.js.map +1 -0
- package/src/lib/engine/run-event-automations.d.ts +28 -0
- package/src/lib/engine/run-event-automations.js +37 -0
- package/src/lib/engine/run-event-automations.js.map +1 -0
- package/src/lib/engine/run-event-workflows.d.ts +75 -0
- package/src/lib/engine/run-event-workflows.js +311 -0
- package/src/lib/engine/run-event-workflows.js.map +1 -0
- package/src/lib/engine/workflow-steps.d.ts +107 -0
- package/src/lib/engine/workflow-steps.js +124 -0
- package/src/lib/engine/workflow-steps.js.map +1 -0
- package/src/lib/plugin.d.ts +33 -0
- package/src/lib/plugin.js +94 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server-automation-drafts.d.ts +90 -0
- package/src/lib/server-automation-drafts.js +429 -0
- package/src/lib/server-automation-drafts.js.map +1 -0
- package/src/lib/server-console.d.ts +27 -0
- package/src/lib/server-console.js +35 -0
- package/src/lib/server-console.js.map +1 -0
- package/src/lib/server.d.ts +29 -0
- package/src/lib/server.js +298 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginJobHostGate } from '@aglyn/aglyn/server';
|
|
18
|
+
import type { WorkflowStep } from './workflow-steps';
|
|
19
|
+
/**
|
|
20
|
+
* `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action, and
|
|
21
|
+
* `{workflow-<workflowId>__personKey}` for a workflow.
|
|
22
|
+
*/
|
|
23
|
+
export declare const FLOW_ENROLLMENTS_SUBCOLLECTION = "flowEnrollments";
|
|
24
|
+
/**
|
|
25
|
+
* Documents one sweep may READ across every site.
|
|
26
|
+
*
|
|
27
|
+
* A bound on work, not on membership — see the note above. Sized well past a
|
|
28
|
+
* normal beat's due queue so the ordinary case never pages at all, and far
|
|
29
|
+
* short of what one API invocation should spend.
|
|
30
|
+
*/
|
|
31
|
+
export declare const FLOW_RESUME_SCAN_BUDGET = 500;
|
|
32
|
+
/**
|
|
33
|
+
* How long a claim may go unfinished before another beat may take it.
|
|
34
|
+
*
|
|
35
|
+
* A `running` row is one a beat is part-way through. If that beat's process
|
|
36
|
+
* ends — a deploy mid-resume, a function timeout, an unhandled throw that
|
|
37
|
+
* escaped the isolation — nothing ever clears the flag, and without this the
|
|
38
|
+
* enrollment would be stranded `running` for ever: not waiting, so no sweep
|
|
39
|
+
* finds it; not done, so nobody is told.
|
|
40
|
+
*
|
|
41
|
+
* Ten minutes is far longer than a resume takes (a handful of Firestore reads
|
|
42
|
+
* and at most one email) and far shorter than the shortest wait anybody
|
|
43
|
+
* authors, so the only thing it can re-arm is a claim nobody is holding.
|
|
44
|
+
*
|
|
45
|
+
* The cost of getting it wrong is a step running twice, which is why the
|
|
46
|
+
* window is generous rather than tight.
|
|
47
|
+
*/
|
|
48
|
+
export declare const FLOW_CLAIM_STALE_MS: number;
|
|
49
|
+
export type FlowEnrollmentStatus = 'waiting' | 'running';
|
|
50
|
+
/**
|
|
51
|
+
* Why an enrollment ended. Recorded on the run history, never on the
|
|
52
|
+
* enrollment — the row is deleted when the flow ends, so the activity
|
|
53
|
+
* collection is where "what happened to this person" lives.
|
|
54
|
+
*/
|
|
55
|
+
export type FlowEnrollmentEnding =
|
|
56
|
+
/** Every remaining step ran. */
|
|
57
|
+
'completed'
|
|
58
|
+
/** An `exitFlow` step, or a guard that ended the run. */
|
|
59
|
+
| 'exited'
|
|
60
|
+
/** The action was deleted, disabled, or lost its entitlement. */
|
|
61
|
+
| 'flow-stopped';
|
|
62
|
+
export interface FlowEnrollment {
|
|
63
|
+
hostId: string;
|
|
64
|
+
/**
|
|
65
|
+
* What is waiting: `workflow` for a workflow's run, absent for an action's
|
|
66
|
+
* — which is every enrollment written before a workflow could wait, so an
|
|
67
|
+
* absent field is read as an action for as long as those rows exist.
|
|
68
|
+
*/
|
|
69
|
+
automation?: 'workflow';
|
|
70
|
+
/** The automation's document id: the action's, or the workflow's. */
|
|
71
|
+
actionId: string;
|
|
72
|
+
actionName: string;
|
|
73
|
+
status: FlowEnrollmentStatus;
|
|
74
|
+
/** When this flow continues. ALWAYS set — the sweep orders on it. */
|
|
75
|
+
resumeAtMs: number;
|
|
76
|
+
/** Index into {@link steps} of the step that runs on resume. */
|
|
77
|
+
nextStepIndex: number;
|
|
78
|
+
/**
|
|
79
|
+
* THE FLOW AS IT WAS WHEN THIS PERSON ENTERED IT.
|
|
80
|
+
*
|
|
81
|
+
* Not a reference to the action's current steps, and this is the whole
|
|
82
|
+
* answer to "what happens to a flow somebody is already waiting inside".
|
|
83
|
+
* `nextStepIndex` is a position in a LIST, and an author who reorders,
|
|
84
|
+
* inserts or deletes a step moves every position after it — so resuming a
|
|
85
|
+
* three-day wait against the edited list would deliver whatever now happens
|
|
86
|
+
* to sit at index 4. That is not a changed flow, it is a scrambled one, and
|
|
87
|
+
* the person it happens to is the one who cannot see it.
|
|
88
|
+
*
|
|
89
|
+
* So an edit applies to everyone who enrolls AFTER it, and nobody who is
|
|
90
|
+
* already inside. The trade is that a merchant who fixes a typo in step 3
|
|
91
|
+
* does not fix it for the people mid-wait; the alternative trade is sending
|
|
92
|
+
* them a step from a different flow, which is worse and silent.
|
|
93
|
+
*
|
|
94
|
+
* Bounded by `ACTION_MAX_STEPS` for an action, so the snapshot is ten
|
|
95
|
+
* small objects, and by `WORKFLOW_MAX_STEPS` for a workflow, whose snapshot
|
|
96
|
+
* holds its function calls beside its Actions steps.
|
|
97
|
+
*/
|
|
98
|
+
steps: WorkflowStep[];
|
|
99
|
+
/** The event a `waitForEvent` is watching for; absent for a plain wait. */
|
|
100
|
+
awaitingEvent?: string | null;
|
|
101
|
+
/** `sha256` of the person's address — the wake lookup key. */
|
|
102
|
+
personKey: string;
|
|
103
|
+
email: string;
|
|
104
|
+
/** The trigger payload, carried forward so later steps see the same scope. */
|
|
105
|
+
payload: Record<string, unknown>;
|
|
106
|
+
/** The event that started the flow, for the run-history line. */
|
|
107
|
+
event: string;
|
|
108
|
+
enrolledAtMs: number;
|
|
109
|
+
updatedAtMs: number;
|
|
110
|
+
/** How many times this enrollment has been picked up. */
|
|
111
|
+
resumes: number;
|
|
112
|
+
/** Set while `running`; how {@link FLOW_CLAIM_STALE_MS} is measured. */
|
|
113
|
+
claimedAtMs?: number;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* ONE LIVE ENROLLMENT PER PERSON PER FLOW, by construction.
|
|
117
|
+
*
|
|
118
|
+
* The document id is derived rather than generated, so a second concurrent
|
|
119
|
+
* enrollment is not a race to detect — it is a write to a document that
|
|
120
|
+
* already exists. A shopper who abandons three carts in an hour gets one
|
|
121
|
+
* recovery sequence, not three overlapping ones, and the guarantee holds
|
|
122
|
+
* across processes because it is Firestore's and not a lock of ours.
|
|
123
|
+
*
|
|
124
|
+
* DELIBERATE, and reversible: a flow that genuinely wants concurrent
|
|
125
|
+
* enrollments would key on something narrower than the person (the cart, the
|
|
126
|
+
* order), which is a change to this function and to nothing else.
|
|
127
|
+
*/
|
|
128
|
+
export declare function flowEnrollmentId(actionId: string, key: string, automation?: 'workflow'): string;
|
|
129
|
+
export interface EnrollInFlowOptions {
|
|
130
|
+
hostId: string;
|
|
131
|
+
/** `workflow` for a workflow's run; absent for an action's. */
|
|
132
|
+
automation?: 'workflow';
|
|
133
|
+
/** The automation's document id. */
|
|
134
|
+
actionId: string;
|
|
135
|
+
action: {
|
|
136
|
+
name?: string;
|
|
137
|
+
steps?: readonly WorkflowStep[];
|
|
138
|
+
};
|
|
139
|
+
/** The address the flow is about. A flow with no person cannot wait. */
|
|
140
|
+
email: string;
|
|
141
|
+
event: string;
|
|
142
|
+
payload: Record<string, unknown>;
|
|
143
|
+
/** Index of the step that runs when the wait ends. */
|
|
144
|
+
nextStepIndex: number;
|
|
145
|
+
resumeAtMs: number;
|
|
146
|
+
awaitingEvent?: string | null;
|
|
147
|
+
nowMs?: number;
|
|
148
|
+
firestore?: any;
|
|
149
|
+
}
|
|
150
|
+
export type EnrollInFlowResult = {
|
|
151
|
+
enrolled: true;
|
|
152
|
+
id: string;
|
|
153
|
+
} | {
|
|
154
|
+
enrolled: false;
|
|
155
|
+
reason: 'no-person' | 'already-waiting';
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Suspends a run, durably.
|
|
159
|
+
*
|
|
160
|
+
* Refuses rather than improvises when there is nobody to wait for. A flow
|
|
161
|
+
* that waits is a flow that continues later for a PERSON — it is how the
|
|
162
|
+
* enrollment is keyed, how a `waitForEvent` is woken, and who the next step's
|
|
163
|
+
* email is addressed to. An anonymous wait would have no dedupe key, so a
|
|
164
|
+
* page-view trigger on a busy site would mint an enrollment per visit; and
|
|
165
|
+
* nothing downstream could use it. Saying so is better than silently
|
|
166
|
+
* enrolling nobody.
|
|
167
|
+
*/
|
|
168
|
+
export declare function enrollInFlow(options: EnrollInFlowOptions): Promise<EnrollInFlowResult>;
|
|
169
|
+
/**
|
|
170
|
+
* Takes an enrollment out of the queue for this beat, or answers null.
|
|
171
|
+
*
|
|
172
|
+
* The claim and the read are one transaction, so two beats — an overlapping
|
|
173
|
+
* schedule, a retried invocation, two regions — cannot both resume the same
|
|
174
|
+
* person. `campaign-process-scheduled.ts` claims a due campaign exactly this
|
|
175
|
+
* way and for exactly this reason.
|
|
176
|
+
*/
|
|
177
|
+
export declare function claimFlowEnrollment(ref: FirebaseFirestore.DocumentReference, options?: {
|
|
178
|
+
nowMs?: number;
|
|
179
|
+
firestore?: any;
|
|
180
|
+
}): Promise<FlowEnrollment | null>;
|
|
181
|
+
/**
|
|
182
|
+
* Puts a claimed enrollment back without having run it.
|
|
183
|
+
*
|
|
184
|
+
* SKIPPED, NOT DROPPED. Used when the resume cannot proceed for a reason a
|
|
185
|
+
* later beat may pass — the platform's hourly send ceiling had no room, the
|
|
186
|
+
* recipient's own frequency window is full — so the step is attempted again
|
|
187
|
+
* rather than lost. `retryAtMs` pushes the row down the queue so it does not
|
|
188
|
+
* spin on the same refusal every minute.
|
|
189
|
+
*/
|
|
190
|
+
export declare function deferFlowEnrollment(ref: FirebaseFirestore.DocumentReference, retryAtMs: number, nowMs?: number): Promise<void>;
|
|
191
|
+
/** Advances a claimed enrollment to the next wait, keeping it in the queue. */
|
|
192
|
+
export declare function advanceFlowEnrollment(ref: FirebaseFirestore.DocumentReference, update: {
|
|
193
|
+
nextStepIndex: number;
|
|
194
|
+
resumeAtMs: number;
|
|
195
|
+
awaitingEvent?: string | null;
|
|
196
|
+
payload?: Record<string, unknown>;
|
|
197
|
+
}, nowMs?: number): Promise<void>;
|
|
198
|
+
/**
|
|
199
|
+
* Ends an enrollment by DELETING it.
|
|
200
|
+
*
|
|
201
|
+
* The row is live state, not a record. What happened to this person is
|
|
202
|
+
* already written to `hosts/{hostId}/activity` — the same run history every
|
|
203
|
+
* immediate action writes — so keeping a `done` row would be a second,
|
|
204
|
+
* shorter answer to a question that is already answered, growing for ever in
|
|
205
|
+
* a collection the sweep has to index.
|
|
206
|
+
*
|
|
207
|
+
* Deleting is also what lets the same person enter the same flow again: the
|
|
208
|
+
* id is derived from the person, so a tombstone would be a permanent refusal
|
|
209
|
+
* to ever run this sequence for them a second time.
|
|
210
|
+
*/
|
|
211
|
+
export declare function endFlowEnrollment(ref: FirebaseFirestore.DocumentReference): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* The enrollments this person's event should wake, on this site.
|
|
214
|
+
*
|
|
215
|
+
* Keyed, bounded and cheap: three equality filters and a small limit, which
|
|
216
|
+
* Firestore serves by merging single-field indexes. This is what keeps
|
|
217
|
+
* `waitForEvent` off a polling design — nothing scans the enrolled population
|
|
218
|
+
* looking for a match, the event arrives already knowing who it is about.
|
|
219
|
+
*
|
|
220
|
+
* The CALLER decides whether to ask at all. `runEventActions` fires on every
|
|
221
|
+
* page view of every published site, so asking on every event would be a
|
|
222
|
+
* query per visitor; it asks only when the payload names a person, which a
|
|
223
|
+
* page view does not.
|
|
224
|
+
*/
|
|
225
|
+
export declare function findFlowEnrollmentsAwaiting(options: {
|
|
226
|
+
hostId: string;
|
|
227
|
+
event: string;
|
|
228
|
+
email: string;
|
|
229
|
+
firestore?: any;
|
|
230
|
+
limit?: number;
|
|
231
|
+
}): Promise<FirebaseFirestore.QueryDocumentSnapshot[]>;
|
|
232
|
+
/** Where a partial sweep stopped, so the next beat resumes rather than restarts. */
|
|
233
|
+
export interface FlowSweepCursor {
|
|
234
|
+
/** Full document path of the last enrollment read. */
|
|
235
|
+
path: string;
|
|
236
|
+
}
|
|
237
|
+
export interface FlowSweepResult {
|
|
238
|
+
/** Due enrollments read, against the budget. */
|
|
239
|
+
scanned: number;
|
|
240
|
+
/** Enrollments this beat claimed and ran. */
|
|
241
|
+
resumed: number;
|
|
242
|
+
/** Rows left untouched because their site is locked. */
|
|
243
|
+
skippedLocked: number;
|
|
244
|
+
/** Rows another beat already held. */
|
|
245
|
+
skippedClaimed: number;
|
|
246
|
+
/** False when the scan budget ran out — `cursor` says where to resume. */
|
|
247
|
+
complete: boolean;
|
|
248
|
+
cursor: FlowSweepCursor | null;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* One pass over the flows whose wait has ended.
|
|
252
|
+
*
|
|
253
|
+
* The RESUME itself is injected. Running a step needs the action executor,
|
|
254
|
+
* which needs entitlements, datasets, webhooks and the mail path; keeping it
|
|
255
|
+
* out of this function is what lets the scheduling contract — due-ness, the
|
|
256
|
+
* claim, the budget, the lock — be exercised without any of that.
|
|
257
|
+
*/
|
|
258
|
+
export declare function sweepDueFlowEnrollments(gate: PluginJobHostGate, options: {
|
|
259
|
+
resume: (enrollment: FlowEnrollment, ref: FirebaseFirestore.DocumentReference) => Promise<void>;
|
|
260
|
+
nowMs?: number;
|
|
261
|
+
scanBudget?: number;
|
|
262
|
+
cursor?: FlowSweepCursor | null;
|
|
263
|
+
firestore?: any;
|
|
264
|
+
}): Promise<FlowSweepResult>;
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* WHERE A PERSON WAITS, between one step of a flow and the next.
|
|
19
|
+
*
|
|
20
|
+
* An action used to be trigger → immediate steps, start to finish inside the
|
|
21
|
+
* request that emitted the event. A `wait` step ends that: the run stops, and
|
|
22
|
+
* something else has to pick it up minutes or weeks later. This module is the
|
|
23
|
+
* something else's memory.
|
|
24
|
+
*
|
|
25
|
+
* ## The scheduling model, and why it survives a deploy
|
|
26
|
+
*
|
|
27
|
+
* There is no timer anywhere. A wait is a ROW — `resumeAtMs` on a document in
|
|
28
|
+
* `hosts/{hostId}/flowEnrollments` — and the resume is a query for rows whose
|
|
29
|
+
* time has come, run by the platform job beat that already exists. Nothing is
|
|
30
|
+
* held in a process, so nothing is lost when the process ends: a deploy, a
|
|
31
|
+
* restart, a cold start and a region failover all leave the row exactly where
|
|
32
|
+
* it was, and the next beat finds it. The two worked examples in this repo
|
|
33
|
+
* take the same shape for the same reason — `campaign-process-scheduled.ts`
|
|
34
|
+
* claims `status: 'scheduled'` campaigns whose `sendAtMs` has passed, and the
|
|
35
|
+
* abandoned-checkout scan re-reads any checkout it did not stamp.
|
|
36
|
+
*
|
|
37
|
+
* A beat that dies mid-resume is the case worth stating. The claim below flips
|
|
38
|
+
* `waiting` → `running` in a transaction, so a second beat cannot pick up the
|
|
39
|
+
* same enrollment; and a `running` row whose beat never came back is re-armed
|
|
40
|
+
* by {@link FLOW_CLAIM_STALE_MS}, which is the difference between "somebody is
|
|
41
|
+
* working on this" and "somebody died holding this".
|
|
42
|
+
*
|
|
43
|
+
* ## What it costs, which is not "every enrollment on every beat"
|
|
44
|
+
*
|
|
45
|
+
* The sweep reads DUE rows and only due rows: the query carries
|
|
46
|
+
* `resumeAtMs <= now`, so a site with ten thousand people waiting three days
|
|
47
|
+
* costs nothing on the beats before those three days are up. That is the whole
|
|
48
|
+
* difference between this and the naive design, which re-reads every enrolled
|
|
49
|
+
* person on every beat to ask whether their time has come.
|
|
50
|
+
*
|
|
51
|
+
* On top of that, {@link FLOW_RESUME_SCAN_BUDGET} bounds one beat's work and a
|
|
52
|
+
* cursor resumes it — the shape `dynamic-list-materialize.ts` uses. The budget
|
|
53
|
+
* matters even though the query is already narrow, because a due row that
|
|
54
|
+
* cannot be acted on (a locked host) stays due: without paging past it, one
|
|
55
|
+
* wall of locked rows at the head of the queue would starve every enrollment
|
|
56
|
+
* behind it for as long as the lock lasted.
|
|
57
|
+
*
|
|
58
|
+
* ## ⛔ A BUDGET NEVER DROPS AN ENROLLMENT
|
|
59
|
+
*
|
|
60
|
+
* Running out of budget stops the sweep where it is. It never marks an
|
|
61
|
+
* enrollment done, never advances its step, and never deletes it — the row
|
|
62
|
+
* stays `waiting` and overdue, and the next beat starts again from the front
|
|
63
|
+
* of the queue, which is where the oldest overdue work is. The same rule the
|
|
64
|
+
* list materializer states: a bound on WORK is not a bound on PEOPLE.
|
|
65
|
+
*/ import { personKey } from "@aglyn/aglyn/server";
|
|
66
|
+
import { firebaseAdmin } from "@aglyn/tenant-data-admin";
|
|
67
|
+
/**
|
|
68
|
+
* `hosts/{hostId}/flowEnrollments/{actionId__personKey}` for an action, and
|
|
69
|
+
* `{workflow-<workflowId>__personKey}` for a workflow.
|
|
70
|
+
*/ export const FLOW_ENROLLMENTS_SUBCOLLECTION = 'flowEnrollments';
|
|
71
|
+
/**
|
|
72
|
+
* Documents one sweep may READ across every site.
|
|
73
|
+
*
|
|
74
|
+
* A bound on work, not on membership — see the note above. Sized well past a
|
|
75
|
+
* normal beat's due queue so the ordinary case never pages at all, and far
|
|
76
|
+
* short of what one API invocation should spend.
|
|
77
|
+
*/ export const FLOW_RESUME_SCAN_BUDGET = 500;
|
|
78
|
+
/** Due enrollments read per page. */ const PAGE_SIZE = 50;
|
|
79
|
+
/**
|
|
80
|
+
* How long a claim may go unfinished before another beat may take it.
|
|
81
|
+
*
|
|
82
|
+
* A `running` row is one a beat is part-way through. If that beat's process
|
|
83
|
+
* ends — a deploy mid-resume, a function timeout, an unhandled throw that
|
|
84
|
+
* escaped the isolation — nothing ever clears the flag, and without this the
|
|
85
|
+
* enrollment would be stranded `running` for ever: not waiting, so no sweep
|
|
86
|
+
* finds it; not done, so nobody is told.
|
|
87
|
+
*
|
|
88
|
+
* Ten minutes is far longer than a resume takes (a handful of Firestore reads
|
|
89
|
+
* and at most one email) and far shorter than the shortest wait anybody
|
|
90
|
+
* authors, so the only thing it can re-arm is a claim nobody is holding.
|
|
91
|
+
*
|
|
92
|
+
* The cost of getting it wrong is a step running twice, which is why the
|
|
93
|
+
* window is generous rather than tight.
|
|
94
|
+
*/ export const FLOW_CLAIM_STALE_MS = 10 * 60000;
|
|
95
|
+
/**
|
|
96
|
+
* ONE LIVE ENROLLMENT PER PERSON PER FLOW, by construction.
|
|
97
|
+
*
|
|
98
|
+
* The document id is derived rather than generated, so a second concurrent
|
|
99
|
+
* enrollment is not a race to detect — it is a write to a document that
|
|
100
|
+
* already exists. A shopper who abandons three carts in an hour gets one
|
|
101
|
+
* recovery sequence, not three overlapping ones, and the guarantee holds
|
|
102
|
+
* across processes because it is Firestore's and not a lock of ours.
|
|
103
|
+
*
|
|
104
|
+
* DELIBERATE, and reversible: a flow that genuinely wants concurrent
|
|
105
|
+
* enrollments would key on something narrower than the person (the cart, the
|
|
106
|
+
* order), which is a change to this function and to nothing else.
|
|
107
|
+
*/ export function flowEnrollmentId(actionId, key, automation) {
|
|
108
|
+
// A workflow's id is kept apart from an action's, so the two kinds can
|
|
109
|
+
// never share a row whatever ids their collections hand out.
|
|
110
|
+
return automation === 'workflow' ? `workflow-${actionId}__${key}` : `${actionId}__${key}`;
|
|
111
|
+
}
|
|
112
|
+
function enrollmentsRef(hostId, firestore) {
|
|
113
|
+
return (firestore != null ? firestore : firebaseAdmin.app().firestore()).collection('hosts').doc(hostId).collection(FLOW_ENROLLMENTS_SUBCOLLECTION);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Suspends a run, durably.
|
|
117
|
+
*
|
|
118
|
+
* Refuses rather than improvises when there is nobody to wait for. A flow
|
|
119
|
+
* that waits is a flow that continues later for a PERSON — it is how the
|
|
120
|
+
* enrollment is keyed, how a `waitForEvent` is woken, and who the next step's
|
|
121
|
+
* email is addressed to. An anonymous wait would have no dedupe key, so a
|
|
122
|
+
* page-view trigger on a busy site would mint an enrollment per visit; and
|
|
123
|
+
* nothing downstream could use it. Saying so is better than silently
|
|
124
|
+
* enrolling nobody.
|
|
125
|
+
*/ export async function enrollInFlow(options) {
|
|
126
|
+
var _options_nowMs, _options_firestore;
|
|
127
|
+
const key = personKey(options.email);
|
|
128
|
+
if (!key) return {
|
|
129
|
+
enrolled: false,
|
|
130
|
+
reason: 'no-person'
|
|
131
|
+
};
|
|
132
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
133
|
+
const ref = enrollmentsRef(options.hostId, options.firestore).doc(flowEnrollmentId(options.actionId, key, options.automation));
|
|
134
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
135
|
+
return await firestore.runTransaction(async (transaction)=>{
|
|
136
|
+
var _options_action_name, _options_action_steps, _options_payload;
|
|
137
|
+
const existing = await transaction.get(ref);
|
|
138
|
+
/*
|
|
139
|
+
* A row that is `waiting` or freshly `running` belongs to a live
|
|
140
|
+
* enrollment and this one is a duplicate. A STALE `running` row is a
|
|
141
|
+
* claim nobody is holding (see FLOW_CLAIM_STALE_MS) and a completed
|
|
142
|
+
* flow deletes its row, so anything else is free to be overwritten —
|
|
143
|
+
* which is what lets the same person go through the same welcome series
|
|
144
|
+
* again next year.
|
|
145
|
+
*/ if (existing.exists) {
|
|
146
|
+
var _existing_get;
|
|
147
|
+
const status = existing.get('status');
|
|
148
|
+
const claimedAtMs = Number((_existing_get = existing.get('claimedAtMs')) != null ? _existing_get : 0);
|
|
149
|
+
const live = status === 'waiting' || status === 'running' && nowMs - claimedAtMs < FLOW_CLAIM_STALE_MS;
|
|
150
|
+
if (live) {
|
|
151
|
+
return {
|
|
152
|
+
enrolled: false,
|
|
153
|
+
reason: 'already-waiting'
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const enrollment = _extends({
|
|
158
|
+
hostId: options.hostId
|
|
159
|
+
}, options.automation === 'workflow' ? {
|
|
160
|
+
automation: 'workflow'
|
|
161
|
+
} : {}, {
|
|
162
|
+
actionId: options.actionId,
|
|
163
|
+
actionName: String((_options_action_name = options.action.name) != null ? _options_action_name : ''),
|
|
164
|
+
status: 'waiting',
|
|
165
|
+
resumeAtMs: options.resumeAtMs,
|
|
166
|
+
nextStepIndex: options.nextStepIndex,
|
|
167
|
+
steps: [
|
|
168
|
+
...(_options_action_steps = options.action.steps) != null ? _options_action_steps : []
|
|
169
|
+
],
|
|
170
|
+
personKey: key,
|
|
171
|
+
email: String(options.email).trim().toLowerCase(),
|
|
172
|
+
payload: (_options_payload = options.payload) != null ? _options_payload : {},
|
|
173
|
+
event: options.event,
|
|
174
|
+
enrolledAtMs: nowMs,
|
|
175
|
+
updatedAtMs: nowMs,
|
|
176
|
+
resumes: 0
|
|
177
|
+
}, options.awaitingEvent ? {
|
|
178
|
+
awaitingEvent: options.awaitingEvent
|
|
179
|
+
} : {
|
|
180
|
+
awaitingEvent: null
|
|
181
|
+
});
|
|
182
|
+
// `set`, not `create`: the transaction above has already decided this id
|
|
183
|
+
// is free, and a completed flow's deleted row may be re-enrolled.
|
|
184
|
+
transaction.set(ref, enrollment);
|
|
185
|
+
return {
|
|
186
|
+
enrolled: true,
|
|
187
|
+
id: ref.id
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Takes an enrollment out of the queue for this beat, or answers null.
|
|
193
|
+
*
|
|
194
|
+
* The claim and the read are one transaction, so two beats — an overlapping
|
|
195
|
+
* schedule, a retried invocation, two regions — cannot both resume the same
|
|
196
|
+
* person. `campaign-process-scheduled.ts` claims a due campaign exactly this
|
|
197
|
+
* way and for exactly this reason.
|
|
198
|
+
*/ export async function claimFlowEnrollment(ref, options) {
|
|
199
|
+
var _ref, _ref1, _ref2;
|
|
200
|
+
const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
|
|
201
|
+
const firestore = (_ref1 = (_ref2 = options == null ? void 0 : options.firestore) != null ? _ref2 : ref.firestore) != null ? _ref1 : firebaseAdmin.app().firestore();
|
|
202
|
+
return await firestore.runTransaction(async (transaction)=>{
|
|
203
|
+
var _fresh_get;
|
|
204
|
+
const fresh = await transaction.get(ref);
|
|
205
|
+
if (!fresh.exists) return null;
|
|
206
|
+
const status = fresh.get('status');
|
|
207
|
+
const claimedAtMs = Number((_fresh_get = fresh.get('claimedAtMs')) != null ? _fresh_get : 0);
|
|
208
|
+
const claimable = status === 'waiting' || status === 'running' && nowMs - claimedAtMs >= FLOW_CLAIM_STALE_MS;
|
|
209
|
+
if (!claimable) return null;
|
|
210
|
+
transaction.update(ref, {
|
|
211
|
+
status: 'running',
|
|
212
|
+
claimedAtMs: nowMs,
|
|
213
|
+
updatedAtMs: nowMs
|
|
214
|
+
});
|
|
215
|
+
return _extends({}, fresh.data(), {
|
|
216
|
+
status: 'running'
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Puts a claimed enrollment back without having run it.
|
|
222
|
+
*
|
|
223
|
+
* SKIPPED, NOT DROPPED. Used when the resume cannot proceed for a reason a
|
|
224
|
+
* later beat may pass — the platform's hourly send ceiling had no room, the
|
|
225
|
+
* recipient's own frequency window is full — so the step is attempted again
|
|
226
|
+
* rather than lost. `retryAtMs` pushes the row down the queue so it does not
|
|
227
|
+
* spin on the same refusal every minute.
|
|
228
|
+
*/ export async function deferFlowEnrollment(ref, retryAtMs, nowMs = Date.now()) {
|
|
229
|
+
await ref.set({
|
|
230
|
+
status: 'waiting',
|
|
231
|
+
resumeAtMs: retryAtMs,
|
|
232
|
+
claimedAtMs: null,
|
|
233
|
+
updatedAtMs: nowMs
|
|
234
|
+
}, {
|
|
235
|
+
merge: true
|
|
236
|
+
}).catch(()=>undefined);
|
|
237
|
+
}
|
|
238
|
+
/** Advances a claimed enrollment to the next wait, keeping it in the queue. */ export async function advanceFlowEnrollment(ref, update, nowMs = Date.now()) {
|
|
239
|
+
var _update_awaitingEvent;
|
|
240
|
+
await ref.set(_extends({
|
|
241
|
+
status: 'waiting',
|
|
242
|
+
claimedAtMs: null,
|
|
243
|
+
updatedAtMs: nowMs,
|
|
244
|
+
resumes: firebaseAdmin.firestore.FieldValue.increment(1),
|
|
245
|
+
nextStepIndex: update.nextStepIndex,
|
|
246
|
+
resumeAtMs: update.resumeAtMs,
|
|
247
|
+
awaitingEvent: (_update_awaitingEvent = update.awaitingEvent) != null ? _update_awaitingEvent : null
|
|
248
|
+
}, update.payload ? {
|
|
249
|
+
payload: update.payload
|
|
250
|
+
} : {}), {
|
|
251
|
+
merge: true
|
|
252
|
+
}).catch(()=>undefined);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Ends an enrollment by DELETING it.
|
|
256
|
+
*
|
|
257
|
+
* The row is live state, not a record. What happened to this person is
|
|
258
|
+
* already written to `hosts/{hostId}/activity` — the same run history every
|
|
259
|
+
* immediate action writes — so keeping a `done` row would be a second,
|
|
260
|
+
* shorter answer to a question that is already answered, growing for ever in
|
|
261
|
+
* a collection the sweep has to index.
|
|
262
|
+
*
|
|
263
|
+
* Deleting is also what lets the same person enter the same flow again: the
|
|
264
|
+
* id is derived from the person, so a tombstone would be a permanent refusal
|
|
265
|
+
* to ever run this sequence for them a second time.
|
|
266
|
+
*/ export async function endFlowEnrollment(ref) {
|
|
267
|
+
await ref.delete().catch(()=>undefined);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The enrollments this person's event should wake, on this site.
|
|
271
|
+
*
|
|
272
|
+
* Keyed, bounded and cheap: three equality filters and a small limit, which
|
|
273
|
+
* Firestore serves by merging single-field indexes. This is what keeps
|
|
274
|
+
* `waitForEvent` off a polling design — nothing scans the enrolled population
|
|
275
|
+
* looking for a match, the event arrives already knowing who it is about.
|
|
276
|
+
*
|
|
277
|
+
* The CALLER decides whether to ask at all. `runEventActions` fires on every
|
|
278
|
+
* page view of every published site, so asking on every event would be a
|
|
279
|
+
* query per visitor; it asks only when the payload names a person, which a
|
|
280
|
+
* page view does not.
|
|
281
|
+
*/ export async function findFlowEnrollmentsAwaiting(options) {
|
|
282
|
+
var _options_event;
|
|
283
|
+
const key = personKey(options.email);
|
|
284
|
+
const event = String((_options_event = options.event) != null ? _options_event : '').trim();
|
|
285
|
+
if (!key || !event || !options.hostId) return [];
|
|
286
|
+
try {
|
|
287
|
+
var _options_limit;
|
|
288
|
+
const snapshot = await enrollmentsRef(options.hostId, options.firestore).where('awaitingEvent', '==', event).where('personKey', '==', key).where('status', '==', 'waiting').limit((_options_limit = options.limit) != null ? _options_limit : 5).get();
|
|
289
|
+
return snapshot.docs;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
console.error('[flow] awaiting-event lookup failed', options.hostId, error);
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* One pass over the flows whose wait has ended.
|
|
297
|
+
*
|
|
298
|
+
* The RESUME itself is injected. Running a step needs the action executor,
|
|
299
|
+
* which needs entitlements, datasets, webhooks and the mail path; keeping it
|
|
300
|
+
* out of this function is what lets the scheduling contract — due-ness, the
|
|
301
|
+
* claim, the budget, the lock — be exercised without any of that.
|
|
302
|
+
*/ export async function sweepDueFlowEnrollments(gate, options) {
|
|
303
|
+
var _options_firestore, _options_nowMs, _options_scanBudget;
|
|
304
|
+
var _options_cursor;
|
|
305
|
+
const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
306
|
+
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
307
|
+
const budget = (_options_scanBudget = options.scanBudget) != null ? _options_scanBudget : FLOW_RESUME_SCAN_BUDGET;
|
|
308
|
+
const result = {
|
|
309
|
+
scanned: 0,
|
|
310
|
+
resumed: 0,
|
|
311
|
+
skippedLocked: 0,
|
|
312
|
+
skippedClaimed: 0,
|
|
313
|
+
complete: true,
|
|
314
|
+
cursor: null
|
|
315
|
+
};
|
|
316
|
+
let after = ((_options_cursor = options.cursor) == null ? void 0 : _options_cursor.path) ? await firestore.doc(options.cursor.path).get().catch(()=>null) : null;
|
|
317
|
+
for(;;){
|
|
318
|
+
/*
|
|
319
|
+
* Ordered by `resumeAtMs`, which every writer in this module sets on
|
|
320
|
+
* every write. That is load-bearing twice over: an `orderBy` on a field
|
|
321
|
+
* DROPS documents that lack it, so a writer that ever omitted it would
|
|
322
|
+
* make those enrollments invisible to the only thing that resumes them;
|
|
323
|
+
* and ordering oldest-due-first is what makes a budget fair, because the
|
|
324
|
+
* work a short beat leaves behind is the newest rather than whichever
|
|
325
|
+
* rows Firestore happened to return.
|
|
326
|
+
*/ /*
|
|
327
|
+
* The page is capped by what is LEFT of the budget, not only by
|
|
328
|
+
* `PAGE_SIZE`. Checking the budget between pages would let a run whose
|
|
329
|
+
* budget is smaller than one page read the whole page first — the budget
|
|
330
|
+
* would then describe how much was reported rather than how much was
|
|
331
|
+
* read, which is the opposite of what a read budget is for.
|
|
332
|
+
*/ const remaining = budget - result.scanned;
|
|
333
|
+
if (remaining <= 0) break;
|
|
334
|
+
let query = firestore.collectionGroup(FLOW_ENROLLMENTS_SUBCOLLECTION).where('status', '==', 'waiting').where('resumeAtMs', '<=', nowMs).orderBy('resumeAtMs').limit(Math.min(PAGE_SIZE, remaining));
|
|
335
|
+
if (after) query = query.startAfter(after);
|
|
336
|
+
const page = await query.get();
|
|
337
|
+
if (page.empty) break;
|
|
338
|
+
for (const doc of page.docs){
|
|
339
|
+
var _doc_get, _ref;
|
|
340
|
+
var _doc_ref_parent_parent;
|
|
341
|
+
result.scanned += 1;
|
|
342
|
+
after = doc;
|
|
343
|
+
const hostId = String((_doc_get = doc.get('hostId')) != null ? _doc_get : '') || ((_ref = (_doc_ref_parent_parent = doc.ref.parent.parent) == null ? void 0 : _doc_ref_parent_parent.id) != null ? _ref : '');
|
|
344
|
+
if (!hostId) continue;
|
|
345
|
+
/*
|
|
346
|
+
* LOCKDOWN, first in the loop body and before any write. `continue`
|
|
347
|
+
* leaves the row `waiting` and overdue, so the flow resumes on the
|
|
348
|
+
* first beat after the lift rather than being cancelled by a pause.
|
|
349
|
+
*/ if (await gate.isLocked(hostId)) {
|
|
350
|
+
result.skippedLocked += 1;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const enrollment = await claimFlowEnrollment(doc.ref, {
|
|
354
|
+
nowMs
|
|
355
|
+
});
|
|
356
|
+
if (!enrollment) {
|
|
357
|
+
result.skippedClaimed += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
await options.resume(enrollment, doc.ref);
|
|
362
|
+
result.resumed += 1;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
/*
|
|
365
|
+
* One broken flow must not stop the sweep — the same isolation the
|
|
366
|
+
* dynamic-list sweep gives each list. The claim is released so a
|
|
367
|
+
* later beat retries; a permanently broken step therefore retries for
|
|
368
|
+
* as long as the enrollment lives, which is bounded by its own wait
|
|
369
|
+
* ceiling and is the recoverable direction.
|
|
370
|
+
*/ console.error('[flow] resume failed', doc.ref.path, error);
|
|
371
|
+
await deferFlowEnrollment(doc.ref, nowMs + FLOW_CLAIM_STALE_MS, nowMs);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (result.scanned >= budget) {
|
|
375
|
+
result.complete = false;
|
|
376
|
+
result.cursor = after ? {
|
|
377
|
+
path: after.ref.path
|
|
378
|
+
} : null;
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
if (page.size < Math.min(PAGE_SIZE, remaining)) break;
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
//# sourceMappingURL=flow-enrollments.js.map
|