@shware/workflow 9.0.1 → 9.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1404,6 +1404,17 @@ the sweep's maintenance marks an entry left unadmitted for 15 minutes, whose
1404
1404
  instance the runtime does not know or reports ended, as failed, so the user's
1405
1405
  next trigger re-enters. Neither can start a second journey for the pair.
1406
1406
 
1407
+ An instance can also end after it was admitted without finalizing its entry:
1408
+ terminated from the dashboard or the API, errored for good because a step kept
1409
+ failing, or evicted. The same maintenance samples the entries admitted more than
1410
+ 15 minutes ago (a bounded random sample each sweep, so every entry is reached
1411
+ eventually however many journeys are running), and one whose instance the
1412
+ runtime reports `complete`, `errored` or `terminated`, or no longer knows, is
1413
+ marked failed, its wake subscriptions dropped and its ending reported — what the
1414
+ finalize step would have done. An instance still running, or one whose lookup
1415
+ fails for any other reason, is left alone. The next trigger of the pair then
1416
+ re-enters.
1417
+
1407
1418
  ## Exports
1408
1419
 
1409
1420
  | Subpath | Contents |
@@ -10,8 +10,10 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
10
10
  * timeouts, goal and `performed(... within)` windows inside the workflow), so
11
11
  * the journey keeps its shape, just faster. What does not scale: wall-clock
12
12
  * `timeWindow`s (they are times of day, not durations). Segment definitions
13
- * live in the store rather than the workflow body; `scaleSegmentWindows`
14
- * scales them as they are read, so a segment's `within` keeps pace too.
13
+ * and trigger routes live in the store rather than the workflow body;
14
+ * `scaleSegmentWindows` scales them as they are read — one segment for an
15
+ * event-driven evaluation, all of them for the scheduled sweep, a route's
16
+ * profile gate at ingest — so a segment's `within` keeps pace everywhere.
15
17
  *
16
18
  * The walk follows the IR's own structure — the fields that hold a Duration
17
19
  * are known — so a message prop or an event payload that happens to look like
@@ -105,20 +107,40 @@ function scale(duration, factor) {
105
107
  };
106
108
  }
107
109
  /**
108
- * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads
109
- * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)
110
- * shrink with the journeys that reference it, and with the segment triggers it routes. Every
111
- * other method is the store's own.
110
+ * A store whose segment definitions come back scaled by `factor` — what the journey host leases
111
+ * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink
112
+ * with the journeys that reference it, and with the segment triggers it routes. Every read that
113
+ * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),
114
+ * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those
115
+ * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a
116
+ * journey, which `scaleDurations` scales inside the journey too). Every other method is the
117
+ * store's own.
112
118
  */
113
119
  function scaleSegmentWindows(store, factor) {
114
120
  if (!(factor > 0) || factor === 1) return store;
115
121
  return new Proxy(store, { get(target, property) {
116
- if (property === "getSegmentCondition") return async (name) => {
117
- const condition = await target.getSegmentCondition(name);
118
- return condition === void 0 ? void 0 : scaleCondition(condition, factor);
119
- };
120
122
  const value = Reflect.get(target, property, target);
121
- return typeof value === "function" ? value.bind(target) : value;
123
+ if (typeof value !== "function") return value;
124
+ switch (property) {
125
+ case "getSegmentCondition": return async (name) => {
126
+ const condition = await target.getSegmentCondition(name);
127
+ return condition === void 0 ? void 0 : scaleCondition(condition, factor);
128
+ };
129
+ case "listSegments": return async () => {
130
+ return (await value.call(target)).map(({ id, condition }) => ({
131
+ id,
132
+ condition: scaleCondition(condition, factor)
133
+ }));
134
+ };
135
+ case "findTriggers": return async (event) => {
136
+ const scaled = (route) => ({
137
+ ...route,
138
+ filter: route.filter === null ? null : scaleCondition(route.filter, factor)
139
+ });
140
+ return (await value.call(target, event)).map(scaled);
141
+ };
142
+ default: return value.bind(target);
143
+ }
122
144
  } });
123
145
  }
124
146
  exports.scaleCondition = scaleCondition;
@@ -1 +1 @@
1
- {"version":3,"file":"time-scale.cjs","names":[],"sources":["../../src/engine/time-scale.ts"],"sourcesContent":["import type { ConditionIR, DurationIR, NodeIR, WorkflowIR } from '../ir';\n\n/**\n * Debug time scale: divide every duration in a workflow's IR by a factor so a\n * journey written in days plays out in seconds — with a factor of 3600, a\n * `delay('2 days')` sleeps 48 s, a `waitUntil(..., { timeout: '3 days' })`\n * gives up after 72 s, and a goal `within: '30 days'` closes after 12 min.\n *\n * Everything that is a Duration scales together (delays, random delays, wait\n * timeouts, goal and `performed(... within)` windows inside the workflow), so\n * the journey keeps its shape, just faster. What does not scale: wall-clock\n * `timeWindow`s (they are times of day, not durations). Segment definitions\n * live in the store rather than the workflow body; `scaleSegmentWindows`\n * scales them as they are read, so a segment's `within` keeps pace too.\n *\n * The walk follows the IR's own structure — the fields that hold a Duration\n * are known — so a message prop or an event payload that happens to look like\n * one (`{ value, ms }`) is left alone. The source text is kept so the studio\n * still shows what was written.\n */\nexport function scaleDurations(ir: WorkflowIR, factor: number): WorkflowIR {\n if (!(factor > 0) || factor === 1) return ir;\n return {\n ...ir,\n trigger:\n ir.trigger.type === 'event'\n ? {\n ...ir.trigger,\n ...(ir.trigger.filter === undefined\n ? {}\n : { filter: scaleCondition(ir.trigger.filter, factor) }),\n }\n : ir.trigger,\n ...(ir.goal === undefined\n ? {}\n : {\n goal: {\n ...ir.goal,\n condition: scaleCondition(ir.goal.condition, factor),\n ...(ir.goal.within === undefined ? {} : { within: scale(ir.goal.within, factor) }),\n },\n }),\n ...(ir.exitWhen === undefined ? {} : { exitWhen: scaleCondition(ir.exitWhen, factor) }),\n flow: scaleFlow(ir.flow, factor),\n };\n}\n\n/** A condition with its `performed(... within)` windows divided by `factor`. */\nexport function scaleCondition(condition: ConditionIR, factor: number): ConditionIR {\n if (!(factor > 0) || factor === 1) return condition;\n switch (condition.type) {\n case 'and':\n case 'or':\n return {\n ...condition,\n conditions: condition.conditions.map((child) => scaleCondition(child, factor)),\n };\n case 'not':\n return { ...condition, condition: scaleCondition(condition.condition, factor) };\n case 'performed':\n return condition.within === undefined\n ? condition\n : { ...condition, within: scale(condition.within, factor) };\n default:\n return condition;\n }\n}\n\nfunction scaleFlow(nodes: readonly NodeIR[], factor: number): NodeIR[] {\n return nodes.map((node) => scaleNode(node, factor));\n}\n\nfunction scaleNode(node: NodeIR, factor: number): NodeIR {\n switch (node.type) {\n case 'delay':\n return { ...node, duration: scale(node.duration, factor) };\n case 'random_delay':\n return { ...node, min: scale(node.min, factor), max: scale(node.max, factor) };\n case 'wait_until':\n return {\n ...node,\n condition: scaleCondition(node.condition, factor),\n timeout: scale(node.timeout, factor),\n onTimeout: Array.isArray(node.onTimeout)\n ? scaleFlow(node.onTimeout, factor)\n : node.onTimeout,\n };\n case 'branch':\n return {\n ...node,\n cases: node.cases.map((branch) => ({\n ...branch,\n condition: scaleCondition(branch.condition, factor),\n flow: scaleFlow(branch.flow, factor),\n })),\n ...(node.otherwise === undefined ? {} : { otherwise: scaleFlow(node.otherwise, factor) }),\n };\n case 'filter':\n return { ...node, condition: scaleCondition(node.condition, factor) };\n case 'cohort':\n return {\n ...node,\n arms: node.arms.map((arm) => ({ ...arm, flow: scaleFlow(arm.flow, factor) })),\n };\n default:\n return node;\n }\n}\n\nfunction scale(duration: DurationIR, factor: number): DurationIR {\n return { value: duration.value, ms: Math.max(0, Math.round(duration.ms / factor)) };\n}\n\n/**\n * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads\n * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)\n * shrink with the journeys that reference it, and with the segment triggers it routes. Every\n * other method is the store's own.\n */\nexport function scaleSegmentWindows<\n S extends { getSegmentCondition(name: string): Promise<ConditionIR | undefined> },\n>(store: S, factor: number): S {\n if (!(factor > 0) || factor === 1) return store;\n return new Proxy(store, {\n get(target, property) {\n if (property === 'getSegmentCondition') {\n return async (name: string) => {\n const condition = await target.getSegmentCondition(name);\n return condition === undefined ? undefined : scaleCondition(condition, factor);\n };\n }\n const value: unknown = Reflect.get(target, property, target);\n return typeof value === 'function' ? value.bind(target) : value;\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"time-scale.cjs","names":[],"sources":["../../src/engine/time-scale.ts"],"sourcesContent":["import type { ConditionIR, DurationIR, NodeIR, WorkflowIR } from '../ir';\n\n/**\n * Debug time scale: divide every duration in a workflow's IR by a factor so a\n * journey written in days plays out in seconds — with a factor of 3600, a\n * `delay('2 days')` sleeps 48 s, a `waitUntil(..., { timeout: '3 days' })`\n * gives up after 72 s, and a goal `within: '30 days'` closes after 12 min.\n *\n * Everything that is a Duration scales together (delays, random delays, wait\n * timeouts, goal and `performed(... within)` windows inside the workflow), so\n * the journey keeps its shape, just faster. What does not scale: wall-clock\n * `timeWindow`s (they are times of day, not durations). Segment definitions\n * and trigger routes live in the store rather than the workflow body;\n * `scaleSegmentWindows` scales them as they are read — one segment for an\n * event-driven evaluation, all of them for the scheduled sweep, a route's\n * profile gate at ingest — so a segment's `within` keeps pace everywhere.\n *\n * The walk follows the IR's own structure — the fields that hold a Duration\n * are known — so a message prop or an event payload that happens to look like\n * one (`{ value, ms }`) is left alone. The source text is kept so the studio\n * still shows what was written.\n */\nexport function scaleDurations(ir: WorkflowIR, factor: number): WorkflowIR {\n if (!(factor > 0) || factor === 1) return ir;\n return {\n ...ir,\n trigger:\n ir.trigger.type === 'event'\n ? {\n ...ir.trigger,\n ...(ir.trigger.filter === undefined\n ? {}\n : { filter: scaleCondition(ir.trigger.filter, factor) }),\n }\n : ir.trigger,\n ...(ir.goal === undefined\n ? {}\n : {\n goal: {\n ...ir.goal,\n condition: scaleCondition(ir.goal.condition, factor),\n ...(ir.goal.within === undefined ? {} : { within: scale(ir.goal.within, factor) }),\n },\n }),\n ...(ir.exitWhen === undefined ? {} : { exitWhen: scaleCondition(ir.exitWhen, factor) }),\n flow: scaleFlow(ir.flow, factor),\n };\n}\n\n/** A condition with its `performed(... within)` windows divided by `factor`. */\nexport function scaleCondition(condition: ConditionIR, factor: number): ConditionIR {\n if (!(factor > 0) || factor === 1) return condition;\n switch (condition.type) {\n case 'and':\n case 'or':\n return {\n ...condition,\n conditions: condition.conditions.map((child) => scaleCondition(child, factor)),\n };\n case 'not':\n return { ...condition, condition: scaleCondition(condition.condition, factor) };\n case 'performed':\n return condition.within === undefined\n ? condition\n : { ...condition, within: scale(condition.within, factor) };\n default:\n return condition;\n }\n}\n\nfunction scaleFlow(nodes: readonly NodeIR[], factor: number): NodeIR[] {\n return nodes.map((node) => scaleNode(node, factor));\n}\n\nfunction scaleNode(node: NodeIR, factor: number): NodeIR {\n switch (node.type) {\n case 'delay':\n return { ...node, duration: scale(node.duration, factor) };\n case 'random_delay':\n return { ...node, min: scale(node.min, factor), max: scale(node.max, factor) };\n case 'wait_until':\n return {\n ...node,\n condition: scaleCondition(node.condition, factor),\n timeout: scale(node.timeout, factor),\n onTimeout: Array.isArray(node.onTimeout)\n ? scaleFlow(node.onTimeout, factor)\n : node.onTimeout,\n };\n case 'branch':\n return {\n ...node,\n cases: node.cases.map((branch) => ({\n ...branch,\n condition: scaleCondition(branch.condition, factor),\n flow: scaleFlow(branch.flow, factor),\n })),\n ...(node.otherwise === undefined ? {} : { otherwise: scaleFlow(node.otherwise, factor) }),\n };\n case 'filter':\n return { ...node, condition: scaleCondition(node.condition, factor) };\n case 'cohort':\n return {\n ...node,\n arms: node.arms.map((arm) => ({ ...arm, flow: scaleFlow(arm.flow, factor) })),\n };\n default:\n return node;\n }\n}\n\nfunction scale(duration: DurationIR, factor: number): DurationIR {\n return { value: duration.value, ms: Math.max(0, Math.round(duration.ms / factor)) };\n}\n\n/**\n * A store whose segment definitions come back scaled by `factor` — what the journey host leases\n * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink\n * with the journeys that reference it, and with the segment triggers it routes. Every read that\n * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),\n * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those\n * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a\n * journey, which `scaleDurations` scales inside the journey too). Every other method is the\n * store's own.\n */\nexport function scaleSegmentWindows<\n S extends { getSegmentCondition(name: string): Promise<ConditionIR | undefined> },\n>(store: S, factor: number): S {\n if (!(factor > 0) || factor === 1) return store;\n return new Proxy(store, {\n get(target, property) {\n const value: unknown = Reflect.get(target, property, target);\n if (typeof value !== 'function') return value;\n switch (property) {\n case 'getSegmentCondition':\n return async (name: string) => {\n const condition = await target.getSegmentCondition(name);\n return condition === undefined ? undefined : scaleCondition(condition, factor);\n };\n case 'listSegments':\n return async () => {\n const segments = (await value.call(target)) as { id: string; condition: ConditionIR }[];\n return segments.map(({ id, condition }) => ({\n id,\n condition: scaleCondition(condition, factor),\n }));\n };\n case 'findTriggers':\n return async (event: string) => {\n // Only the profile gate: `where` is payload-only, so it holds no window to scale\n const scaled = (route: { filter: ConditionIR | null }) => ({\n ...route,\n filter: route.filter === null ? null : scaleCondition(route.filter, factor),\n });\n return ((await value.call(target, event)) as { filter: ConditionIR | null }[]).map(\n scaled\n );\n };\n default:\n return value.bind(target);\n }\n },\n });\n}\n"],"mappings":"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"}
@@ -9,8 +9,10 @@ import { ConditionIR, WorkflowIR } from "../ir.cjs";
9
9
  * timeouts, goal and `performed(... within)` windows inside the workflow), so
10
10
  * the journey keeps its shape, just faster. What does not scale: wall-clock
11
11
  * `timeWindow`s (they are times of day, not durations). Segment definitions
12
- * live in the store rather than the workflow body; `scaleSegmentWindows`
13
- * scales them as they are read, so a segment's `within` keeps pace too.
12
+ * and trigger routes live in the store rather than the workflow body;
13
+ * `scaleSegmentWindows` scales them as they are read — one segment for an
14
+ * event-driven evaluation, all of them for the scheduled sweep, a route's
15
+ * profile gate at ingest — so a segment's `within` keeps pace everywhere.
14
16
  *
15
17
  * The walk follows the IR's own structure — the fields that hold a Duration
16
18
  * are known — so a message prop or an event payload that happens to look like
@@ -21,10 +23,14 @@ export declare function scaleDurations(ir: WorkflowIR, factor: number): Workflow
21
23
  /** A condition with its `performed(... within)` windows divided by `factor`. */
22
24
  export declare function scaleCondition(condition: ConditionIR, factor: number): ConditionIR;
23
25
  /**
24
- * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads
25
- * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)
26
- * shrink with the journeys that reference it, and with the segment triggers it routes. Every
27
- * other method is the store's own.
26
+ * A store whose segment definitions come back scaled by `factor` — what the journey host leases
27
+ * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink
28
+ * with the journeys that reference it, and with the segment triggers it routes. Every read that
29
+ * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),
30
+ * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those
31
+ * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a
32
+ * journey, which `scaleDurations` scales inside the journey too). Every other method is the
33
+ * store's own.
28
34
  */
29
35
  export declare function scaleSegmentWindows<S extends {
30
36
  getSegmentCondition(name: string): Promise<ConditionIR | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"time-scale.d.cts","names":[],"sources":["../../src/engine/time-scale.ts"],"mappings":";;;;;;;;;;;;;;;;;;;wBAoBgB,eAAe,IAAI,YAAY,iBAAiB;;wBA4BhD,eAAe,WAAW,aAAa,iBAAiB;;;;;;;wBAuExD,oBACd;EAAY,oBAAoB,eAAe,QAAQ;GACvD,OAAO,GAAG,iBAAiB"}
1
+ {"version":3,"file":"time-scale.d.cts","names":[],"sources":["../../src/engine/time-scale.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;wBAsBgB,eAAe,IAAI,YAAY,iBAAiB;;wBA4BhD,eAAe,WAAW,aAAa,iBAAiB;;;;;;;;;;;wBA2ExD,oBACd;EAAY,oBAAoB,eAAe,QAAQ;GACvD,OAAO,GAAG,iBAAiB"}
@@ -9,8 +9,10 @@ import { ConditionIR, WorkflowIR } from "../ir.mjs";
9
9
  * timeouts, goal and `performed(... within)` windows inside the workflow), so
10
10
  * the journey keeps its shape, just faster. What does not scale: wall-clock
11
11
  * `timeWindow`s (they are times of day, not durations). Segment definitions
12
- * live in the store rather than the workflow body; `scaleSegmentWindows`
13
- * scales them as they are read, so a segment's `within` keeps pace too.
12
+ * and trigger routes live in the store rather than the workflow body;
13
+ * `scaleSegmentWindows` scales them as they are read — one segment for an
14
+ * event-driven evaluation, all of them for the scheduled sweep, a route's
15
+ * profile gate at ingest — so a segment's `within` keeps pace everywhere.
14
16
  *
15
17
  * The walk follows the IR's own structure — the fields that hold a Duration
16
18
  * are known — so a message prop or an event payload that happens to look like
@@ -21,10 +23,14 @@ export declare function scaleDurations(ir: WorkflowIR, factor: number): Workflow
21
23
  /** A condition with its `performed(... within)` windows divided by `factor`. */
22
24
  export declare function scaleCondition(condition: ConditionIR, factor: number): ConditionIR;
23
25
  /**
24
- * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads
25
- * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)
26
- * shrink with the journeys that reference it, and with the segment triggers it routes. Every
27
- * other method is the store's own.
26
+ * A store whose segment definitions come back scaled by `factor` — what the journey host leases
27
+ * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink
28
+ * with the journeys that reference it, and with the segment triggers it routes. Every read that
29
+ * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),
30
+ * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those
31
+ * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a
32
+ * journey, which `scaleDurations` scales inside the journey too). Every other method is the
33
+ * store's own.
28
34
  */
29
35
  export declare function scaleSegmentWindows<S extends {
30
36
  getSegmentCondition(name: string): Promise<ConditionIR | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"time-scale.d.mts","names":[],"sources":["../../src/engine/time-scale.ts"],"mappings":";;;;;;;;;;;;;;;;;;;wBAoBgB,eAAe,IAAI,YAAY,iBAAiB;;wBA4BhD,eAAe,WAAW,aAAa,iBAAiB;;;;;;;wBAuExD,oBACd;EAAY,oBAAoB,eAAe,QAAQ;GACvD,OAAO,GAAG,iBAAiB"}
1
+ {"version":3,"file":"time-scale.d.mts","names":[],"sources":["../../src/engine/time-scale.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;wBAsBgB,eAAe,IAAI,YAAY,iBAAiB;;wBA4BhD,eAAe,WAAW,aAAa,iBAAiB;;;;;;;;;;;wBA2ExD,oBACd;EAAY,oBAAoB,eAAe,QAAQ;GACvD,OAAO,GAAG,iBAAiB"}
@@ -9,8 +9,10 @@
9
9
  * timeouts, goal and `performed(... within)` windows inside the workflow), so
10
10
  * the journey keeps its shape, just faster. What does not scale: wall-clock
11
11
  * `timeWindow`s (they are times of day, not durations). Segment definitions
12
- * live in the store rather than the workflow body; `scaleSegmentWindows`
13
- * scales them as they are read, so a segment's `within` keeps pace too.
12
+ * and trigger routes live in the store rather than the workflow body;
13
+ * `scaleSegmentWindows` scales them as they are read — one segment for an
14
+ * event-driven evaluation, all of them for the scheduled sweep, a route's
15
+ * profile gate at ingest — so a segment's `within` keeps pace everywhere.
14
16
  *
15
17
  * The walk follows the IR's own structure — the fields that hold a Duration
16
18
  * are known — so a message prop or an event payload that happens to look like
@@ -104,20 +106,40 @@ function scale(duration, factor) {
104
106
  };
105
107
  }
106
108
  /**
107
- * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads
108
- * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)
109
- * shrink with the journeys that reference it, and with the segment triggers it routes. Every
110
- * other method is the store's own.
109
+ * A store whose segment definitions come back scaled by `factor` — what the journey host leases
110
+ * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink
111
+ * with the journeys that reference it, and with the segment triggers it routes. Every read that
112
+ * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),
113
+ * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those
114
+ * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a
115
+ * journey, which `scaleDurations` scales inside the journey too). Every other method is the
116
+ * store's own.
111
117
  */
112
118
  function scaleSegmentWindows(store, factor) {
113
119
  if (!(factor > 0) || factor === 1) return store;
114
120
  return new Proxy(store, { get(target, property) {
115
- if (property === "getSegmentCondition") return async (name) => {
116
- const condition = await target.getSegmentCondition(name);
117
- return condition === void 0 ? void 0 : scaleCondition(condition, factor);
118
- };
119
121
  const value = Reflect.get(target, property, target);
120
- return typeof value === "function" ? value.bind(target) : value;
122
+ if (typeof value !== "function") return value;
123
+ switch (property) {
124
+ case "getSegmentCondition": return async (name) => {
125
+ const condition = await target.getSegmentCondition(name);
126
+ return condition === void 0 ? void 0 : scaleCondition(condition, factor);
127
+ };
128
+ case "listSegments": return async () => {
129
+ return (await value.call(target)).map(({ id, condition }) => ({
130
+ id,
131
+ condition: scaleCondition(condition, factor)
132
+ }));
133
+ };
134
+ case "findTriggers": return async (event) => {
135
+ const scaled = (route) => ({
136
+ ...route,
137
+ filter: route.filter === null ? null : scaleCondition(route.filter, factor)
138
+ });
139
+ return (await value.call(target, event)).map(scaled);
140
+ };
141
+ default: return value.bind(target);
142
+ }
121
143
  } });
122
144
  }
123
145
  export { scaleCondition, scaleDurations, scaleSegmentWindows };
@@ -1 +1 @@
1
- {"version":3,"file":"time-scale.mjs","names":[],"sources":["../../src/engine/time-scale.ts"],"sourcesContent":["import type { ConditionIR, DurationIR, NodeIR, WorkflowIR } from '../ir';\n\n/**\n * Debug time scale: divide every duration in a workflow's IR by a factor so a\n * journey written in days plays out in seconds — with a factor of 3600, a\n * `delay('2 days')` sleeps 48 s, a `waitUntil(..., { timeout: '3 days' })`\n * gives up after 72 s, and a goal `within: '30 days'` closes after 12 min.\n *\n * Everything that is a Duration scales together (delays, random delays, wait\n * timeouts, goal and `performed(... within)` windows inside the workflow), so\n * the journey keeps its shape, just faster. What does not scale: wall-clock\n * `timeWindow`s (they are times of day, not durations). Segment definitions\n * live in the store rather than the workflow body; `scaleSegmentWindows`\n * scales them as they are read, so a segment's `within` keeps pace too.\n *\n * The walk follows the IR's own structure — the fields that hold a Duration\n * are known — so a message prop or an event payload that happens to look like\n * one (`{ value, ms }`) is left alone. The source text is kept so the studio\n * still shows what was written.\n */\nexport function scaleDurations(ir: WorkflowIR, factor: number): WorkflowIR {\n if (!(factor > 0) || factor === 1) return ir;\n return {\n ...ir,\n trigger:\n ir.trigger.type === 'event'\n ? {\n ...ir.trigger,\n ...(ir.trigger.filter === undefined\n ? {}\n : { filter: scaleCondition(ir.trigger.filter, factor) }),\n }\n : ir.trigger,\n ...(ir.goal === undefined\n ? {}\n : {\n goal: {\n ...ir.goal,\n condition: scaleCondition(ir.goal.condition, factor),\n ...(ir.goal.within === undefined ? {} : { within: scale(ir.goal.within, factor) }),\n },\n }),\n ...(ir.exitWhen === undefined ? {} : { exitWhen: scaleCondition(ir.exitWhen, factor) }),\n flow: scaleFlow(ir.flow, factor),\n };\n}\n\n/** A condition with its `performed(... within)` windows divided by `factor`. */\nexport function scaleCondition(condition: ConditionIR, factor: number): ConditionIR {\n if (!(factor > 0) || factor === 1) return condition;\n switch (condition.type) {\n case 'and':\n case 'or':\n return {\n ...condition,\n conditions: condition.conditions.map((child) => scaleCondition(child, factor)),\n };\n case 'not':\n return { ...condition, condition: scaleCondition(condition.condition, factor) };\n case 'performed':\n return condition.within === undefined\n ? condition\n : { ...condition, within: scale(condition.within, factor) };\n default:\n return condition;\n }\n}\n\nfunction scaleFlow(nodes: readonly NodeIR[], factor: number): NodeIR[] {\n return nodes.map((node) => scaleNode(node, factor));\n}\n\nfunction scaleNode(node: NodeIR, factor: number): NodeIR {\n switch (node.type) {\n case 'delay':\n return { ...node, duration: scale(node.duration, factor) };\n case 'random_delay':\n return { ...node, min: scale(node.min, factor), max: scale(node.max, factor) };\n case 'wait_until':\n return {\n ...node,\n condition: scaleCondition(node.condition, factor),\n timeout: scale(node.timeout, factor),\n onTimeout: Array.isArray(node.onTimeout)\n ? scaleFlow(node.onTimeout, factor)\n : node.onTimeout,\n };\n case 'branch':\n return {\n ...node,\n cases: node.cases.map((branch) => ({\n ...branch,\n condition: scaleCondition(branch.condition, factor),\n flow: scaleFlow(branch.flow, factor),\n })),\n ...(node.otherwise === undefined ? {} : { otherwise: scaleFlow(node.otherwise, factor) }),\n };\n case 'filter':\n return { ...node, condition: scaleCondition(node.condition, factor) };\n case 'cohort':\n return {\n ...node,\n arms: node.arms.map((arm) => ({ ...arm, flow: scaleFlow(arm.flow, factor) })),\n };\n default:\n return node;\n }\n}\n\nfunction scale(duration: DurationIR, factor: number): DurationIR {\n return { value: duration.value, ms: Math.max(0, Math.round(duration.ms / factor)) };\n}\n\n/**\n * A store whose segment definitions come back scaled by `factor` — what journeyWorker reads\n * through in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`)\n * shrink with the journeys that reference it, and with the segment triggers it routes. Every\n * other method is the store's own.\n */\nexport function scaleSegmentWindows<\n S extends { getSegmentCondition(name: string): Promise<ConditionIR | undefined> },\n>(store: S, factor: number): S {\n if (!(factor > 0) || factor === 1) return store;\n return new Proxy(store, {\n get(target, property) {\n if (property === 'getSegmentCondition') {\n return async (name: string) => {\n const condition = await target.getSegmentCondition(name);\n return condition === undefined ? undefined : scaleCondition(condition, factor);\n };\n }\n const value: unknown = Reflect.get(target, property, target);\n return typeof value === 'function' ? value.bind(target) : value;\n },\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"time-scale.mjs","names":[],"sources":["../../src/engine/time-scale.ts"],"sourcesContent":["import type { ConditionIR, DurationIR, NodeIR, WorkflowIR } from '../ir';\n\n/**\n * Debug time scale: divide every duration in a workflow's IR by a factor so a\n * journey written in days plays out in seconds — with a factor of 3600, a\n * `delay('2 days')` sleeps 48 s, a `waitUntil(..., { timeout: '3 days' })`\n * gives up after 72 s, and a goal `within: '30 days'` closes after 12 min.\n *\n * Everything that is a Duration scales together (delays, random delays, wait\n * timeouts, goal and `performed(... within)` windows inside the workflow), so\n * the journey keeps its shape, just faster. What does not scale: wall-clock\n * `timeWindow`s (they are times of day, not durations). Segment definitions\n * and trigger routes live in the store rather than the workflow body;\n * `scaleSegmentWindows` scales them as they are read — one segment for an\n * event-driven evaluation, all of them for the scheduled sweep, a route's\n * profile gate at ingest — so a segment's `within` keeps pace everywhere.\n *\n * The walk follows the IR's own structure — the fields that hold a Duration\n * are known — so a message prop or an event payload that happens to look like\n * one (`{ value, ms }`) is left alone. The source text is kept so the studio\n * still shows what was written.\n */\nexport function scaleDurations(ir: WorkflowIR, factor: number): WorkflowIR {\n if (!(factor > 0) || factor === 1) return ir;\n return {\n ...ir,\n trigger:\n ir.trigger.type === 'event'\n ? {\n ...ir.trigger,\n ...(ir.trigger.filter === undefined\n ? {}\n : { filter: scaleCondition(ir.trigger.filter, factor) }),\n }\n : ir.trigger,\n ...(ir.goal === undefined\n ? {}\n : {\n goal: {\n ...ir.goal,\n condition: scaleCondition(ir.goal.condition, factor),\n ...(ir.goal.within === undefined ? {} : { within: scale(ir.goal.within, factor) }),\n },\n }),\n ...(ir.exitWhen === undefined ? {} : { exitWhen: scaleCondition(ir.exitWhen, factor) }),\n flow: scaleFlow(ir.flow, factor),\n };\n}\n\n/** A condition with its `performed(... within)` windows divided by `factor`. */\nexport function scaleCondition(condition: ConditionIR, factor: number): ConditionIR {\n if (!(factor > 0) || factor === 1) return condition;\n switch (condition.type) {\n case 'and':\n case 'or':\n return {\n ...condition,\n conditions: condition.conditions.map((child) => scaleCondition(child, factor)),\n };\n case 'not':\n return { ...condition, condition: scaleCondition(condition.condition, factor) };\n case 'performed':\n return condition.within === undefined\n ? condition\n : { ...condition, within: scale(condition.within, factor) };\n default:\n return condition;\n }\n}\n\nfunction scaleFlow(nodes: readonly NodeIR[], factor: number): NodeIR[] {\n return nodes.map((node) => scaleNode(node, factor));\n}\n\nfunction scaleNode(node: NodeIR, factor: number): NodeIR {\n switch (node.type) {\n case 'delay':\n return { ...node, duration: scale(node.duration, factor) };\n case 'random_delay':\n return { ...node, min: scale(node.min, factor), max: scale(node.max, factor) };\n case 'wait_until':\n return {\n ...node,\n condition: scaleCondition(node.condition, factor),\n timeout: scale(node.timeout, factor),\n onTimeout: Array.isArray(node.onTimeout)\n ? scaleFlow(node.onTimeout, factor)\n : node.onTimeout,\n };\n case 'branch':\n return {\n ...node,\n cases: node.cases.map((branch) => ({\n ...branch,\n condition: scaleCondition(branch.condition, factor),\n flow: scaleFlow(branch.flow, factor),\n })),\n ...(node.otherwise === undefined ? {} : { otherwise: scaleFlow(node.otherwise, factor) }),\n };\n case 'filter':\n return { ...node, condition: scaleCondition(node.condition, factor) };\n case 'cohort':\n return {\n ...node,\n arms: node.arms.map((arm) => ({ ...arm, flow: scaleFlow(arm.flow, factor) })),\n };\n default:\n return node;\n }\n}\n\nfunction scale(duration: DurationIR, factor: number): DurationIR {\n return { value: duration.value, ms: Math.max(0, Math.round(duration.ms / factor)) };\n}\n\n/**\n * A store whose segment definitions come back scaled by `factor` — what the journey host leases\n * in fast-forward, so a segment's windows (`performed(e.login, { within: '7 days' })`) shrink\n * with the journeys that reference it, and with the segment triggers it routes. Every read that\n * hands out a stored condition is scaled: `getSegmentCondition` (event-driven evaluation),\n * `listSegments` (the scheduled sweep, which narrows candidates in SQL and evaluates with those\n * definitions) and `findTriggers` (the profile gate an event trigger checks before it starts a\n * journey, which `scaleDurations` scales inside the journey too). Every other method is the\n * store's own.\n */\nexport function scaleSegmentWindows<\n S extends { getSegmentCondition(name: string): Promise<ConditionIR | undefined> },\n>(store: S, factor: number): S {\n if (!(factor > 0) || factor === 1) return store;\n return new Proxy(store, {\n get(target, property) {\n const value: unknown = Reflect.get(target, property, target);\n if (typeof value !== 'function') return value;\n switch (property) {\n case 'getSegmentCondition':\n return async (name: string) => {\n const condition = await target.getSegmentCondition(name);\n return condition === undefined ? undefined : scaleCondition(condition, factor);\n };\n case 'listSegments':\n return async () => {\n const segments = (await value.call(target)) as { id: string; condition: ConditionIR }[];\n return segments.map(({ id, condition }) => ({\n id,\n condition: scaleCondition(condition, factor),\n }));\n };\n case 'findTriggers':\n return async (event: string) => {\n // Only the profile gate: `where` is payload-only, so it holds no window to scale\n const scaled = (route: { filter: ConditionIR | null }) => ({\n ...route,\n filter: route.filter === null ? null : scaleCondition(route.filter, factor),\n });\n return ((await value.call(target, event)) as { filter: ConditionIR | null }[]).map(\n scaled\n );\n };\n default:\n return value.bind(target);\n }\n },\n });\n}\n"],"mappings":"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"}
@@ -119,6 +119,24 @@ async function refreshSegmentTriggers(env, store, userId, ts) {
119
119
  * and act on each membership transition: entering records the membership and starts the
120
120
  * segment's routed workflows, leaving drops it. A segment trigger therefore starts journeys for
121
121
  * users who enter after it is deployed; a user already a member is not entered again.
122
+ *
123
+ * Why the membership writes and the starts are separate store calls, and why that is safe:
124
+ * - Each segment's transition is one store operation. `addSegmentMember` writes the membership
125
+ * and the starts it owes (`segment_start`) in one locked transaction; `removeSegmentMember`
126
+ * drops both in one transaction. No failure leaves a segment half-entered or half-left.
127
+ * - Starts come only from `pendingSegmentStarts`, which exists only once that transaction has
128
+ * committed, so a journey is never started for a membership that was not recorded. A start that
129
+ * fails stays pending and is retried by the next evaluation (the sweep, the user's next event);
130
+ * it is crossed off only once an instance is known to exist (`entryStarted`).
131
+ * - A retried or concurrent start cannot run a journey twice: `startJourney` goes through the
132
+ * entry ledger, keyed by (workflow, user) under the once policy, and the membership insert is
133
+ * `ON CONFLICT DO NOTHING`.
134
+ * - Segments are independent memberships. A failure on one leaves the others where they were;
135
+ * every evaluation recomputes each from facts and the stored membership, so the next one
136
+ * converges rather than building on a partial run.
137
+ * Folding every segment's writes into one transaction would add nothing: the starts have to happen
138
+ * after the commit anyway (a workflow instance cannot join a database transaction), which is what
139
+ * the `segment_start` outbox already provides.
122
140
  */
123
141
  async function refreshSegments(env, store, userId, ts, routes, segments = [], definitions = /* @__PURE__ */ new Map()) {
124
142
  if (require_store_identity.isVisitorSubject(userId)) return [];
@@ -199,6 +217,63 @@ async function failLostStarts(env, store, now) {
199
217
  }
200
218
  return failed;
201
219
  }
220
+ /**
221
+ * How long after its admission an entry is left out of the lost-instance sample. No age proves
222
+ * anything about an admitted instance — it can end a moment after admitting itself, and a long
223
+ * wait runs for weeks — so this is not a judgment, only a filter: the freshest entries are the
224
+ * ones most likely to still be running, and the ones a platform status may not have settled
225
+ * for yet. Correctness rests on the lookup and the conditional update, not on the window.
226
+ */
227
+ const LOST_INSTANCE_AFTER_MS = 9e5;
228
+ /**
229
+ * Admitted entries one maintenance run looks up on the platform: a random sample (see
230
+ * `JourneyStore.admittedEntries`), so every entry is reached eventually however many are
231
+ * running, and the sweep's platform calls stay bounded.
232
+ */
233
+ const LOST_INSTANCE_BATCH = 50;
234
+ /**
235
+ * Instances that ended on the platform without finalizing their entry: terminated from outside
236
+ * (the dashboard, the API), errored for good because a finalize step kept failing, or no longer
237
+ * known to the runtime at all. The entry stays `running`, and the once-policy would bar the user
238
+ * from the workflow for good. Only admitted entries are looked at — an unadmitted one is
239
+ * `failLostStarts`' — and only a terminal status, or not found, ends one: an admitted instance
240
+ * did reach the runtime, so not found now means it finished and was evicted (as `stopInstance`
241
+ * reads it). A lookup that fails otherwise proves nothing and leaves the entry alone. The update
242
+ * is conditional on the entry still running under the instance id, so a run that finalized
243
+ * between the read and the lookup keeps its own status.
244
+ *
245
+ * `complete` ends in `failed` too: the run's finalize step is what records the real outcome, and
246
+ * an instance complete with its entry still running went without it, so what the journey would
247
+ * have recorded is unknown. Platform-neutral: on AWS the adapter's own maintenance reconciles
248
+ * these too (both writes are idempotent), and its `status` reads running through a stop's
249
+ * drain, so nothing is failed here that the adapter would still wait for.
250
+ */
251
+ async function failLostInstances(env, store, now) {
252
+ const failed = [];
253
+ for (const entry of await store.admittedEntries(now - LOST_INSTANCE_AFTER_MS, LOST_INSTANCE_BATCH)) {
254
+ let status;
255
+ try {
256
+ const reported = await (await env.JOURNEY.get(entry.instanceId)).status?.();
257
+ if (reported === void 0 || !STOPPED_STATUSES.has(reported.status)) continue;
258
+ status = reported.status;
259
+ } catch (error) {
260
+ if (!(error instanceof require_host_platform.InstanceNotFoundError)) continue;
261
+ status = "not found";
262
+ }
263
+ if (!await store.failAdmittedEntry(entry.instanceId)) continue;
264
+ failed.push(entry.instanceId);
265
+ await store.unsubscribe(entry.instanceId);
266
+ await store.reporting.instanceFinished({
267
+ workflow: entry.workflow,
268
+ instanceId: entry.instanceId,
269
+ userId: entry.userId,
270
+ status: "failed",
271
+ at: now
272
+ });
273
+ console.error(`[workflow] ${entry.instanceId} (${entry.workflow}) ended on the platform (${status}) without finishing: marked failed, the next trigger re-enters`);
274
+ }
275
+ return failed;
276
+ }
202
277
  /** How long a visitor who never signs in is kept after they were last heard from. */
203
278
  const VISITOR_RETENTION_MS = 7776e6;
204
279
  /** Visitors one sweep prunes: the next sweep goes on. */
@@ -207,9 +282,10 @@ const VISITOR_PRUNE_BATCH = 100;
207
282
  const SWEEP_PAGE_SIZE = 500;
208
283
  /**
209
284
  * What the sweep does before it looks at any user: the runtime's own upkeep (AWS: unconfirmed
210
- * starts, executions that ended unseen) and the erasures left unfinished (an instance would not
211
- * stop). A failure of either is logged, never fatal: the pages still run. Users whose erasure is
212
- * still pending afterwards are skipped by every page (`sweepSegmentsPaged`).
285
+ * starts, executions that ended unseen), the entries whose start or instance was lost, and the
286
+ * erasures left unfinished (an instance would not stop). A failure of any is logged, never
287
+ * fatal: the pages still run. Users whose erasure is still pending afterwards are skipped by
288
+ * every page (`sweepSegmentsPaged`).
213
289
  */
214
290
  async function sweepMaintenance(env, store) {
215
291
  try {
@@ -222,6 +298,11 @@ async function sweepMaintenance(env, store) {
222
298
  } catch (error) {
223
299
  console.error("[workflow] reclaiming lost starts failed", error);
224
300
  }
301
+ try {
302
+ await failLostInstances(env, store, Date.now());
303
+ } catch (error) {
304
+ console.error("[workflow] reclaiming lost instances failed", error);
305
+ }
225
306
  try {
226
307
  await store.pruneVisitors(Date.now() - VISITOR_RETENTION_MS, VISITOR_PRUNE_BATCH);
227
308
  } catch (error) {
@@ -803,11 +884,13 @@ async function setSubscriptionsByMessage(env, store, messageId, patch, topics, n
803
884
  };
804
885
  }
805
886
  exports.BundleIntegrityError = BundleIntegrityError;
887
+ exports.LOST_INSTANCE_AFTER_MS = LOST_INSTANCE_AFTER_MS;
806
888
  exports.LOST_START_AFTER_MS = LOST_START_AFTER_MS;
807
889
  exports.SUBSCRIPTION_CHANNELS = SUBSCRIPTION_CHANNELS;
808
890
  exports.SWEEP_PAGE_SIZE = SWEEP_PAGE_SIZE;
809
891
  exports.VISITOR_RETENTION_MS = VISITOR_RETENTION_MS;
810
892
  exports.deployBundle = deployBundle;
893
+ exports.failLostInstances = failLostInstances;
811
894
  exports.failLostStarts = failLostStarts;
812
895
  exports.forgetUser = forgetUser;
813
896
  exports.getSubscriptions = getSubscriptions;