@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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/package.json +53 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/automation-step-fields.component.d.ts +74 -0
  8. package/src/lib/components/automation-step-fields.component.js +1210 -0
  9. package/src/lib/components/automation-step-fields.component.js.map +1 -0
  10. package/src/lib/components/host-actions-card.component.d.ts +15 -0
  11. package/src/lib/components/host-actions-card.component.js +1229 -0
  12. package/src/lib/components/host-actions-card.component.js.map +1 -0
  13. package/src/lib/components/host-activity-card.component.d.ts +84 -0
  14. package/src/lib/components/host-activity-card.component.js +280 -0
  15. package/src/lib/components/host-activity-card.component.js.map +1 -0
  16. package/src/lib/components/host-run-history-card.component.d.ts +40 -0
  17. package/src/lib/components/host-run-history-card.component.js +284 -0
  18. package/src/lib/components/host-run-history-card.component.js.map +1 -0
  19. package/src/lib/components/host-webhooks-card.component.d.ts +15 -0
  20. package/src/lib/components/host-webhooks-card.component.js +421 -0
  21. package/src/lib/components/host-webhooks-card.component.js.map +1 -0
  22. package/src/lib/components/host-workflows-card.component.d.ts +18 -0
  23. package/src/lib/components/host-workflows-card.component.js +952 -0
  24. package/src/lib/components/host-workflows-card.component.js.map +1 -0
  25. package/src/lib/components/run-quota-line.component.d.ts +27 -0
  26. package/src/lib/components/run-quota-line.component.js +72 -0
  27. package/src/lib/components/run-quota-line.component.js.map +1 -0
  28. package/src/lib/components/use-automation-step-pickers.d.ts +27 -0
  29. package/src/lib/components/use-automation-step-pickers.js +205 -0
  30. package/src/lib/components/use-automation-step-pickers.js.map +1 -0
  31. package/src/lib/components/workflow-zones.d.ts +36 -0
  32. package/src/lib/components/workflow-zones.js +19 -0
  33. package/src/lib/components/workflow-zones.js.map +1 -0
  34. package/src/lib/components/workflows-console-page.d.ts +25 -0
  35. package/src/lib/components/workflows-console-page.js +112 -0
  36. package/src/lib/components/workflows-console-page.js.map +1 -0
  37. package/src/lib/components/workflows-console-sections.d.ts +34 -0
  38. package/src/lib/components/workflows-console-sections.js +50 -0
  39. package/src/lib/components/workflows-console-sections.js.map +1 -0
  40. package/src/lib/constants/bundle-common.d.ts +8 -0
  41. package/src/lib/constants/bundle-common.js +9 -0
  42. package/src/lib/constants/bundle-common.js.map +1 -0
  43. package/src/lib/declarations.server.d.ts +41 -0
  44. package/src/lib/declarations.server.js +54 -0
  45. package/src/lib/declarations.server.js.map +1 -0
  46. package/src/lib/engine/crm-action-steps.d.ts +129 -0
  47. package/src/lib/engine/crm-action-steps.js +432 -0
  48. package/src/lib/engine/crm-action-steps.js.map +1 -0
  49. package/src/lib/engine/flow-enrollments.d.ts +264 -0
  50. package/src/lib/engine/flow-enrollments.js +386 -0
  51. package/src/lib/engine/flow-enrollments.js.map +1 -0
  52. package/src/lib/engine/run-event-actions.d.ts +232 -0
  53. package/src/lib/engine/run-event-actions.js +1392 -0
  54. package/src/lib/engine/run-event-actions.js.map +1 -0
  55. package/src/lib/engine/run-event-automations.d.ts +28 -0
  56. package/src/lib/engine/run-event-automations.js +37 -0
  57. package/src/lib/engine/run-event-automations.js.map +1 -0
  58. package/src/lib/engine/run-event-workflows.d.ts +75 -0
  59. package/src/lib/engine/run-event-workflows.js +311 -0
  60. package/src/lib/engine/run-event-workflows.js.map +1 -0
  61. package/src/lib/engine/workflow-steps.d.ts +107 -0
  62. package/src/lib/engine/workflow-steps.js +124 -0
  63. package/src/lib/engine/workflow-steps.js.map +1 -0
  64. package/src/lib/plugin.d.ts +33 -0
  65. package/src/lib/plugin.js +94 -0
  66. package/src/lib/plugin.js.map +1 -0
  67. package/src/lib/server-automation-drafts.d.ts +90 -0
  68. package/src/lib/server-automation-drafts.js +429 -0
  69. package/src/lib/server-automation-drafts.js.map +1 -0
  70. package/src/lib/server-console.d.ts +27 -0
  71. package/src/lib/server-console.js +35 -0
  72. package/src/lib/server-console.js.map +1 -0
  73. package/src/lib/server.d.ts +29 -0
  74. package/src/lib/server.js +298 -0
  75. 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