@teleporthq/teleport-plugin-next-workflows 0.43.45 → 0.43.47
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/__tests__/_helpers/run-generated-middleware.ts +130 -0
- package/__tests__/auth-env-secret-preservation.test.ts +35 -0
- package/__tests__/auth-nextauth-url.test.ts +28 -0
- package/__tests__/auth-session-cost.test.ts +614 -0
- package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
- package/__tests__/data-node-not-awaited.test.ts +644 -0
- package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
- package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
- package/__tests__/form-submitted-trigger-context.test.ts +100 -0
- package/__tests__/middleware-guest-order-details.test.ts +258 -0
- package/__tests__/middleware-row-owned-pages.test.ts +32 -9
- package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
- package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
- package/__tests__/raw-sql-param-binding.test.ts +82 -0
- package/__tests__/reserved-word-state-binding.test.ts +112 -0
- package/__tests__/strip-event-handler-attrs.test.ts +59 -0
- package/__tests__/workflow-auth-guard.test.ts +277 -0
- package/dist/cjs/api-route-generator.d.ts +3 -3
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +136 -11
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/auth-generator.d.ts +67 -1
- package/dist/cjs/auth-generator.d.ts.map +1 -1
- package/dist/cjs/auth-generator.js +573 -31
- package/dist/cjs/auth-generator.js.map +1 -1
- package/dist/cjs/await-result.d.ts +31 -0
- package/dist/cjs/await-result.d.ts.map +1 -0
- package/dist/cjs/await-result.js +50 -0
- package/dist/cjs/await-result.js.map +1 -0
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +150 -1
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-get-current.js +44 -11
- package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
- package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
- package/dist/cjs/nodes/account/account-login.js +10 -1
- package/dist/cjs/nodes/account/account-login.js.map +1 -1
- package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/cjs/raw-sql-param-binding.js +32 -0
- package/dist/cjs/raw-sql-param-binding.js.map +1 -1
- package/dist/cjs/segment-splitter.d.ts.map +1 -1
- package/dist/cjs/segment-splitter.js +15 -7
- package/dist/cjs/segment-splitter.js.map +1 -1
- package/dist/cjs/trigger-generator.js +7 -1
- package/dist/cjs/trigger-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/workflow-auth-generator.d.ts +26 -0
- package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
- package/dist/cjs/workflow-auth-generator.js +166 -0
- package/dist/cjs/workflow-auth-generator.js.map +1 -0
- package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-component-plugin.js +124 -17
- package/dist/cjs/workflow-component-plugin.js.map +1 -1
- package/dist/cjs/workflow-project-plugin.d.ts +2 -1
- package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
- package/dist/cjs/workflow-project-plugin.js +170 -20
- package/dist/cjs/workflow-project-plugin.js.map +1 -1
- package/dist/esm/api-route-generator.d.ts +3 -3
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +136 -11
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/auth-generator.d.ts +67 -1
- package/dist/esm/auth-generator.d.ts.map +1 -1
- package/dist/esm/auth-generator.js +568 -30
- package/dist/esm/auth-generator.js.map +1 -1
- package/dist/esm/await-result.d.ts +31 -0
- package/dist/esm/await-result.d.ts.map +1 -0
- package/dist/esm/await-result.js +45 -0
- package/dist/esm/await-result.js.map +1 -0
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +150 -1
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-get-current.js +44 -11
- package/dist/esm/nodes/account/account-get-current.js.map +1 -1
- package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
- package/dist/esm/nodes/account/account-login.js +10 -1
- package/dist/esm/nodes/account/account-login.js.map +1 -1
- package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
- package/dist/esm/raw-sql-param-binding.js +32 -0
- package/dist/esm/raw-sql-param-binding.js.map +1 -1
- package/dist/esm/segment-splitter.d.ts.map +1 -1
- package/dist/esm/segment-splitter.js +15 -7
- package/dist/esm/segment-splitter.js.map +1 -1
- package/dist/esm/trigger-generator.js +7 -1
- package/dist/esm/trigger-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/workflow-auth-generator.d.ts +26 -0
- package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
- package/dist/esm/workflow-auth-generator.js +161 -0
- package/dist/esm/workflow-auth-generator.js.map +1 -0
- package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-component-plugin.js +125 -18
- package/dist/esm/workflow-component-plugin.js.map +1 -1
- package/dist/esm/workflow-project-plugin.d.ts +2 -1
- package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
- package/dist/esm/workflow-project-plugin.js +169 -20
- package/dist/esm/workflow-project-plugin.js.map +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +139 -11
- package/src/auth-generator.ts +600 -30
- package/src/await-result.ts +52 -0
- package/src/executor-generator.ts +150 -1
- package/src/nodes/account/account-get-current.ts +44 -9
- package/src/nodes/account/account-login.ts +12 -1
- package/src/raw-sql-param-binding.ts +33 -0
- package/src/segment-splitter.ts +15 -7
- package/src/trigger-generator.ts +7 -1
- package/src/workflow-auth-generator.ts +187 -0
- package/src/workflow-component-plugin.ts +146 -21
- package/src/workflow-project-plugin.ts +186 -19
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateSharedRuntimeUtilsCode,
|
|
3
|
+
generateClientRuntimeCode,
|
|
4
|
+
generateServerSegmentAPIRoute,
|
|
5
|
+
splitIntoSegments,
|
|
6
|
+
} from '../src'
|
|
7
|
+
import { redactServerNodeConfig } from '../src/segment-splitter'
|
|
8
|
+
import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
|
|
9
|
+
import { isFireAndForgetNode, isFireAndForgetSegment } from '../src/await-result'
|
|
10
|
+
import type { WorkflowSegment } from '../src/types'
|
|
11
|
+
|
|
12
|
+
// A data node always talks to the database over the network. The workflow
|
|
13
|
+
// editor lets the author opt OUT of awaiting one (`config.awaitResult: false`),
|
|
14
|
+
// which is what makes "add to favourites" feel instant: the insert still runs,
|
|
15
|
+
// but the click handler no longer blocks on a full round trip.
|
|
16
|
+
//
|
|
17
|
+
// The contract this file pins down:
|
|
18
|
+
// 1. the workflow does not wait for the query;
|
|
19
|
+
// 2. the node's context entry is `null` — never a partial or stale value;
|
|
20
|
+
// 3. a failure cannot abort the workflow (it is logged, not thrown);
|
|
21
|
+
// 4. the server route STILL settles the query before it responds, because a
|
|
22
|
+
// serverless function may be frozen the instant it replies;
|
|
23
|
+
// 5. the client dispatches an all-fire-and-forget SEGMENT without awaiting the
|
|
24
|
+
// round trip, which is where the latency the visitor feels actually goes.
|
|
25
|
+
|
|
26
|
+
interface SharedUtils {
|
|
27
|
+
executeNodes: (
|
|
28
|
+
nodes: unknown[],
|
|
29
|
+
edges: unknown[],
|
|
30
|
+
context: Record<string, unknown>,
|
|
31
|
+
handlers: Record<string, unknown>,
|
|
32
|
+
workflowConfig: unknown,
|
|
33
|
+
callServerSegment: unknown,
|
|
34
|
+
executionId: string
|
|
35
|
+
) => Promise<void>
|
|
36
|
+
isFireAndForgetNode: (node: unknown) => boolean
|
|
37
|
+
registerPendingNodePromise: (context: Record<string, unknown>, p: Promise<unknown>) => unknown
|
|
38
|
+
settlePendingNodePromises: (context: Record<string, unknown>) => Promise<void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadSharedRuntime(): SharedUtils {
|
|
42
|
+
const src = generateSharedRuntimeUtilsCode()
|
|
43
|
+
const wrapper: { exports: Record<string, unknown> } = { exports: {} }
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
45
|
+
new Function('module', 'exports', src)(wrapper, wrapper.exports)
|
|
46
|
+
return wrapper.exports as unknown as SharedUtils
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const deferred = () => {
|
|
50
|
+
let resolve: (value?: unknown) => void = () => undefined
|
|
51
|
+
let reject: (reason?: unknown) => void = () => undefined
|
|
52
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
53
|
+
resolve = res as (value?: unknown) => void
|
|
54
|
+
reject = rej
|
|
55
|
+
})
|
|
56
|
+
return { promise, resolve, reject }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dataNode = (id: string, awaitResult?: boolean) => ({
|
|
60
|
+
id,
|
|
61
|
+
type: 'data-create-item',
|
|
62
|
+
label: 'Save Row',
|
|
63
|
+
stepNumber: 1,
|
|
64
|
+
config: {
|
|
65
|
+
dataSourceId: 'ds-1',
|
|
66
|
+
tableName: 'teleport_favourites',
|
|
67
|
+
...(awaitResult === undefined ? {} : { awaitResult }),
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('isFireAndForgetNode — classification', () => {
|
|
72
|
+
it('only opts out on an explicit false', () => {
|
|
73
|
+
expect(isFireAndForgetNode(dataNode('n1', false) as never)).toBe(true)
|
|
74
|
+
expect(isFireAndForgetNode(dataNode('n1', true) as never)).toBe(false)
|
|
75
|
+
expect(isFireAndForgetNode(dataNode('n1') as never)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('ignores the flag on non-data node types', () => {
|
|
79
|
+
// Node replacement copies config across types; a stray flag must not turn
|
|
80
|
+
// an unrelated node fire-and-forget.
|
|
81
|
+
const stray = {
|
|
82
|
+
id: 'x',
|
|
83
|
+
type: 'general-custom-js',
|
|
84
|
+
label: 'Script',
|
|
85
|
+
stepNumber: 1,
|
|
86
|
+
config: { code: 'return {}', awaitResult: false },
|
|
87
|
+
}
|
|
88
|
+
expect(isFireAndForgetNode(stray as never)).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('covers every data node type', () => {
|
|
92
|
+
for (const type of [
|
|
93
|
+
'data-select',
|
|
94
|
+
'data-count',
|
|
95
|
+
'data-raw-query',
|
|
96
|
+
'data-create-item',
|
|
97
|
+
'data-update-item',
|
|
98
|
+
'data-delete-item',
|
|
99
|
+
]) {
|
|
100
|
+
const node = { id: 'n', type, label: type, stepNumber: 1, config: { awaitResult: false } }
|
|
101
|
+
expect(isFireAndForgetNode(node as never)).toBe(true)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('is mirrored inside the generated runtime', () => {
|
|
106
|
+
const utils = loadSharedRuntime()
|
|
107
|
+
expect(utils.isFireAndForgetNode(dataNode('n1', false))).toBe(true)
|
|
108
|
+
expect(utils.isFireAndForgetNode(dataNode('n1'))).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('executeNodes — a non-awaited data node does not block the chain', () => {
|
|
113
|
+
it('continues to the next node while the query is still in flight', async () => {
|
|
114
|
+
const utils = loadSharedRuntime()
|
|
115
|
+
const gate = deferred()
|
|
116
|
+
const order: string[] = []
|
|
117
|
+
|
|
118
|
+
const nodes = [
|
|
119
|
+
dataNode('write', false),
|
|
120
|
+
{ id: 'after', type: 'toast-show', label: 'Toast', stepNumber: 2, config: {} },
|
|
121
|
+
]
|
|
122
|
+
const edges = [{ id: 'e1', source: 'write', target: 'after' }]
|
|
123
|
+
|
|
124
|
+
const handlers = {
|
|
125
|
+
'data-create-item': async () => {
|
|
126
|
+
order.push('write:start')
|
|
127
|
+
await gate.promise
|
|
128
|
+
order.push('write:finish')
|
|
129
|
+
return { id: 'row-1' }
|
|
130
|
+
},
|
|
131
|
+
'toast-show': async () => {
|
|
132
|
+
order.push('toast')
|
|
133
|
+
return { shown: true }
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
138
|
+
await utils.executeNodes(nodes, edges, context, handlers, {}, null, 'exec-1')
|
|
139
|
+
|
|
140
|
+
// The toast ran BEFORE the insert finished — that is the whole point.
|
|
141
|
+
expect(order).toEqual(['write:start', 'toast'])
|
|
142
|
+
// And the node published null, not a half-finished result.
|
|
143
|
+
expect(context.write).toBeNull()
|
|
144
|
+
expect(context.after).toEqual({ shown: true })
|
|
145
|
+
|
|
146
|
+
gate.resolve()
|
|
147
|
+
await utils.settlePendingNodePromises(context)
|
|
148
|
+
expect(order).toEqual(['write:start', 'toast', 'write:finish'])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('still awaits the same node when the flag is absent', async () => {
|
|
152
|
+
const utils = loadSharedRuntime()
|
|
153
|
+
const order: string[] = []
|
|
154
|
+
const nodes = [
|
|
155
|
+
dataNode('write'),
|
|
156
|
+
{ id: 'after', type: 'toast-show', label: 'Toast', stepNumber: 2, config: {} },
|
|
157
|
+
]
|
|
158
|
+
const edges = [{ id: 'e1', source: 'write', target: 'after' }]
|
|
159
|
+
const handlers = {
|
|
160
|
+
'data-create-item': async () => {
|
|
161
|
+
await Promise.resolve()
|
|
162
|
+
order.push('write:finish')
|
|
163
|
+
return { id: 'row-1' }
|
|
164
|
+
},
|
|
165
|
+
'toast-show': async () => {
|
|
166
|
+
order.push('toast')
|
|
167
|
+
return { shown: true }
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
172
|
+
await utils.executeNodes(nodes, edges, context, handlers, {}, null, 'exec-2')
|
|
173
|
+
|
|
174
|
+
expect(order).toEqual(['write:finish', 'toast'])
|
|
175
|
+
expect(context.write).toEqual({ id: 'row-1' })
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('a downstream binding to the non-awaited node resolves to null', async () => {
|
|
179
|
+
const utils = loadSharedRuntime()
|
|
180
|
+
let seen: unknown = 'untouched'
|
|
181
|
+
|
|
182
|
+
const nodes = [
|
|
183
|
+
dataNode('write', false),
|
|
184
|
+
{
|
|
185
|
+
id: 'reader',
|
|
186
|
+
type: 'toast-show',
|
|
187
|
+
label: 'Toast',
|
|
188
|
+
stepNumber: 2,
|
|
189
|
+
config: {
|
|
190
|
+
message: { type: 'workflowContext', nodeId: 'write', path: ['write', 'id'] },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
]
|
|
194
|
+
const edges = [{ id: 'e1', source: 'write', target: 'reader' }]
|
|
195
|
+
const handlers = {
|
|
196
|
+
'data-create-item': async () => ({ id: 'row-1' }),
|
|
197
|
+
'toast-show': async (config: { message?: unknown }) => {
|
|
198
|
+
seen = config.message
|
|
199
|
+
return { shown: true }
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
204
|
+
await utils.executeNodes(nodes, edges, context, handlers, {}, null, 'exec-3')
|
|
205
|
+
// `undefined` (not the written id) — the reference had nothing to drill.
|
|
206
|
+
expect(seen).toBeUndefined()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('executeNodes — a non-awaited failure cannot abort the workflow', () => {
|
|
211
|
+
const silenceConsole = () => {
|
|
212
|
+
// tslint:disable-next-line:no-console
|
|
213
|
+
const original = console.error
|
|
214
|
+
// tslint:disable-next-line:no-console
|
|
215
|
+
console.error = () => undefined
|
|
216
|
+
return () => {
|
|
217
|
+
// tslint:disable-next-line:no-console
|
|
218
|
+
console.error = original
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
it('swallows a rejected handler and keeps going', async () => {
|
|
223
|
+
const utils = loadSharedRuntime()
|
|
224
|
+
const restore = silenceConsole()
|
|
225
|
+
try {
|
|
226
|
+
const nodes = [
|
|
227
|
+
dataNode('write', false),
|
|
228
|
+
{ id: 'after', type: 'toast-show', label: 'Toast', stepNumber: 2, config: {} },
|
|
229
|
+
]
|
|
230
|
+
const edges = [{ id: 'e1', source: 'write', target: 'after' }]
|
|
231
|
+
const handlers = {
|
|
232
|
+
'data-create-item': async () => {
|
|
233
|
+
throw new Error('connection refused')
|
|
234
|
+
},
|
|
235
|
+
'toast-show': async () => ({ shown: true }),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
239
|
+
await expect(
|
|
240
|
+
utils.executeNodes(nodes, edges, context, handlers, {}, null, 'exec-4')
|
|
241
|
+
).resolves.toBeUndefined()
|
|
242
|
+
expect(context.write).toBeNull()
|
|
243
|
+
expect(context.after).toEqual({ shown: true })
|
|
244
|
+
// Draining must not reject either — the promise absorbed the failure.
|
|
245
|
+
await expect(utils.settlePendingNodePromises(context)).resolves.toBeUndefined()
|
|
246
|
+
} finally {
|
|
247
|
+
restore()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('swallows an { error } result instead of throwing it as fatal', async () => {
|
|
252
|
+
const utils = loadSharedRuntime()
|
|
253
|
+
const restore = silenceConsole()
|
|
254
|
+
try {
|
|
255
|
+
const nodes = [dataNode('write', false)]
|
|
256
|
+
const handlers = {
|
|
257
|
+
'data-create-item': async () => ({ error: 'insert failed' }),
|
|
258
|
+
}
|
|
259
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
260
|
+
await expect(
|
|
261
|
+
utils.executeNodes(nodes, [], context, handlers, {}, null, 'exec-5')
|
|
262
|
+
).resolves.toBeUndefined()
|
|
263
|
+
expect(context.write).toBeNull()
|
|
264
|
+
await utils.settlePendingNodePromises(context)
|
|
265
|
+
} finally {
|
|
266
|
+
restore()
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('settlePendingNodePromises', () => {
|
|
272
|
+
it('is a no-op with nothing in flight', async () => {
|
|
273
|
+
const utils = loadSharedRuntime()
|
|
274
|
+
await expect(utils.settlePendingNodePromises({})).resolves.toBeUndefined()
|
|
275
|
+
await expect(
|
|
276
|
+
utils.settlePendingNodePromises({ __pendingNodePromises: [] })
|
|
277
|
+
).resolves.toBeUndefined()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('drains work queued while an earlier promise was settling', async () => {
|
|
281
|
+
const utils = loadSharedRuntime()
|
|
282
|
+
const context: Record<string, unknown> = { __pendingNodePromises: [] }
|
|
283
|
+
const done: string[] = []
|
|
284
|
+
|
|
285
|
+
const second = () =>
|
|
286
|
+
utils.registerPendingNodePromise(
|
|
287
|
+
context,
|
|
288
|
+
Promise.resolve().then(() => {
|
|
289
|
+
done.push('second')
|
|
290
|
+
})
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
utils.registerPendingNodePromise(
|
|
294
|
+
context,
|
|
295
|
+
Promise.resolve().then(() => {
|
|
296
|
+
done.push('first')
|
|
297
|
+
second()
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
await utils.settlePendingNodePromises(context)
|
|
302
|
+
expect(done).toEqual(['first', 'second'])
|
|
303
|
+
expect(context.__pendingNodePromises).toEqual([])
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
// ─── Segment classification ──────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const buildWorkflow = (writeAwaitResult?: boolean) =>
|
|
310
|
+
({
|
|
311
|
+
id: 'wf-1',
|
|
312
|
+
name: 'Toggle Favourite',
|
|
313
|
+
trigger: {
|
|
314
|
+
type: 'event-element-clicked',
|
|
315
|
+
nodeId: 'trigger',
|
|
316
|
+
scope: 'element',
|
|
317
|
+
config: { nodeId: 'heart-btn' },
|
|
318
|
+
},
|
|
319
|
+
nodes: [
|
|
320
|
+
{
|
|
321
|
+
id: 'state',
|
|
322
|
+
type: 'state-update-global-state',
|
|
323
|
+
label: 'Optimistic update',
|
|
324
|
+
config: { property: 'favs', value: [] },
|
|
325
|
+
executionEnv: 'client',
|
|
326
|
+
stepNumber: 1,
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
id: 'write',
|
|
330
|
+
type: 'data-create-item',
|
|
331
|
+
label: 'Add To Favourites Table',
|
|
332
|
+
config: {
|
|
333
|
+
dataSourceId: 'ds-1',
|
|
334
|
+
tableName: 'teleport_favourites',
|
|
335
|
+
columnMappings: [{ column: 'entity_id', value: 'x' }],
|
|
336
|
+
...(writeAwaitResult === undefined ? {} : { awaitResult: writeAwaitResult }),
|
|
337
|
+
},
|
|
338
|
+
executionEnv: 'server',
|
|
339
|
+
stepNumber: 2,
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
edges: [{ id: 'e1', source: 'state', target: 'write' }],
|
|
343
|
+
} as never)
|
|
344
|
+
|
|
345
|
+
describe('segment classification', () => {
|
|
346
|
+
it('marks a segment whose every node is fire-and-forget', () => {
|
|
347
|
+
const segments = splitIntoSegments(buildWorkflow(false))
|
|
348
|
+
const server = segments.filter((s) => s.env === 'server')
|
|
349
|
+
expect(server.length).toBe(1)
|
|
350
|
+
expect(isFireAndForgetSegment(server[0])).toBe(true)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('does not mark the same segment when the write is awaited', () => {
|
|
354
|
+
const segments = splitIntoSegments(buildWorkflow())
|
|
355
|
+
const server = segments.filter((s) => s.env === 'server')
|
|
356
|
+
expect(server.length).toBe(1)
|
|
357
|
+
expect(isFireAndForgetSegment(server[0])).toBe(false)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('does not mark a MIXED segment — one awaited node keeps the whole trip blocking', () => {
|
|
361
|
+
const mixed: WorkflowSegment = {
|
|
362
|
+
id: 'server-1',
|
|
363
|
+
env: 'server',
|
|
364
|
+
nodeIds: ['a', 'b'],
|
|
365
|
+
nodes: [
|
|
366
|
+
{
|
|
367
|
+
id: 'a',
|
|
368
|
+
type: 'data-select',
|
|
369
|
+
label: 'Read',
|
|
370
|
+
config: { awaitResult: false },
|
|
371
|
+
executionEnv: 'server',
|
|
372
|
+
stepNumber: 1,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: 'b',
|
|
376
|
+
type: 'data-select',
|
|
377
|
+
label: 'Read 2',
|
|
378
|
+
config: {},
|
|
379
|
+
executionEnv: 'server',
|
|
380
|
+
stepNumber: 2,
|
|
381
|
+
},
|
|
382
|
+
],
|
|
383
|
+
edges: [],
|
|
384
|
+
}
|
|
385
|
+
expect(isFireAndForgetSegment(mixed)).toBe(false)
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
it('never marks a client segment', () => {
|
|
389
|
+
const clientSeg: WorkflowSegment = {
|
|
390
|
+
id: 'client-1',
|
|
391
|
+
env: 'client',
|
|
392
|
+
nodeIds: [],
|
|
393
|
+
nodes: [],
|
|
394
|
+
edges: [],
|
|
395
|
+
}
|
|
396
|
+
expect(isFireAndForgetSegment(clientSeg)).toBe(false)
|
|
397
|
+
})
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
describe('client config redaction', () => {
|
|
401
|
+
it('keeps awaitResult but drops the query/table/mappings', () => {
|
|
402
|
+
const config = {
|
|
403
|
+
dataSourceId: 'ds-1',
|
|
404
|
+
tableName: 'teleport_favourites',
|
|
405
|
+
query: 'SELECT secret FROM t',
|
|
406
|
+
columnMappings: [{ column: 'entity_id', value: 'x' }],
|
|
407
|
+
awaitResult: false,
|
|
408
|
+
}
|
|
409
|
+
const redacted = redactServerNodeConfig(config, 'server')
|
|
410
|
+
expect(redacted).toEqual({ awaitResult: false })
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('leaves a client-side node config untouched', () => {
|
|
414
|
+
const config = { property: 'favs', awaitResult: false }
|
|
415
|
+
expect(redactServerNodeConfig(config, 'client')).toBe(config)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─── Generated routes ────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
const buildServerSegment = (awaitResult?: boolean): WorkflowSegment => ({
|
|
422
|
+
id: 'server-1',
|
|
423
|
+
env: 'server',
|
|
424
|
+
nodeIds: ['write'],
|
|
425
|
+
nodes: [
|
|
426
|
+
{
|
|
427
|
+
id: 'write',
|
|
428
|
+
type: 'data-create-item',
|
|
429
|
+
label: 'Add To Favourites Table',
|
|
430
|
+
config: {
|
|
431
|
+
dataSourceId: 'ds-1',
|
|
432
|
+
tableName: 'teleport_favourites',
|
|
433
|
+
...(awaitResult === undefined ? {} : { awaitResult }),
|
|
434
|
+
},
|
|
435
|
+
executionEnv: 'server',
|
|
436
|
+
stepNumber: 1,
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
edges: [],
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
describe('generated server segment route', () => {
|
|
443
|
+
const route = generateServerSegmentAPIRoute(buildServerSegment(false), 'Toggle Favourite')
|
|
444
|
+
|
|
445
|
+
it('dispatches a fire-and-forget node instead of awaiting it', () => {
|
|
446
|
+
expect(route).toContain('utils.isFireAndForgetNode(node)')
|
|
447
|
+
expect(route).toContain('utils.startFireAndForgetNode(node, handler, resolved, context)')
|
|
448
|
+
expect(route).toContain('context[node.id] = null;')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('settles in-flight queries BEFORE responding (the platform can freeze us)', () => {
|
|
452
|
+
expect(route).toContain('await utils.settlePendingNodePromises(context);')
|
|
453
|
+
const settleAt = route.indexOf('await utils.settlePendingNodePromises(context);')
|
|
454
|
+
const respondAt = route.indexOf('res.status(200).json({ success: true, results: context });')
|
|
455
|
+
expect(settleAt).toBeGreaterThan(-1)
|
|
456
|
+
expect(respondAt).toBeGreaterThan(settleAt)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('never ships the in-flight promise list back to the client', () => {
|
|
460
|
+
expect(route).toContain('delete context.__pendingNodePromises;')
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('drains even when the segment throws', () => {
|
|
464
|
+
expect(route).toContain(
|
|
465
|
+
'if (__wfContext) { await utils.settlePendingNodePromises(__wfContext); }'
|
|
466
|
+
)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('applies the same treatment inside loop bodies and parallel branches', () => {
|
|
470
|
+
expect(route).toContain('utils.isFireAndForgetNode(bNode)')
|
|
471
|
+
expect(route).toContain('utils.isFireAndForgetNode(pNode)')
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
describe('generated client runtime', () => {
|
|
476
|
+
const client = generateClientRuntimeCode()
|
|
477
|
+
|
|
478
|
+
it('dispatches an all-fire-and-forget segment without awaiting the round trip', () => {
|
|
479
|
+
expect(client).toContain('if (seg.fireAndForget) {')
|
|
480
|
+
expect(client).toContain('callServerSegment(ffUrl, context).catch(')
|
|
481
|
+
// The nodes still get their null entries so downstream reads are defined.
|
|
482
|
+
expect(client).toContain('context[seg.nodes[ffi].id] = null;')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('keeps awaiting a normal server segment', () => {
|
|
486
|
+
expect(client).toContain('const serverResults = await callServerSegment(url, context);')
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
it('never serializes the in-flight promise list to the server', () => {
|
|
490
|
+
expect(client).toContain("if (key === '__pendingNodePromises') continue;")
|
|
491
|
+
})
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// ─── Generated custom node, end to end ───────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
describe('generated custom node — the "Remove From Favourites Logic" shape', () => {
|
|
497
|
+
// Mirrors what teleport-gui's favourites builder emits:
|
|
498
|
+
// Extract Params (client-js) → Delete From Favourites Table (data, NOT
|
|
499
|
+
// awaited) → Get Favourites State → Filter → Update Favourites State
|
|
500
|
+
// The delete is the only server node, so it becomes its own fire-and-forget
|
|
501
|
+
// segment and the click never waits for the database.
|
|
502
|
+
const customNodes = {
|
|
503
|
+
'cn-remove': {
|
|
504
|
+
id: 'cn-remove',
|
|
505
|
+
name: 'Remove From Favourites Logic',
|
|
506
|
+
parameters: [],
|
|
507
|
+
nodes: [
|
|
508
|
+
{
|
|
509
|
+
id: 'extract',
|
|
510
|
+
type: 'general-custom-js',
|
|
511
|
+
label: 'Extract Remove Params',
|
|
512
|
+
config: {
|
|
513
|
+
code: 'function customHandler(p, q) { return { entityId: "e1" } }',
|
|
514
|
+
context: 'client',
|
|
515
|
+
},
|
|
516
|
+
executionEnv: 'client',
|
|
517
|
+
stepNumber: 1,
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
id: 'delete',
|
|
521
|
+
type: 'data-delete-item',
|
|
522
|
+
label: 'Delete From Favourites Table',
|
|
523
|
+
config: { dataSourceId: 'ds-1', tableName: 'teleport_favourites', awaitResult: false },
|
|
524
|
+
executionEnv: 'server',
|
|
525
|
+
stepNumber: 2,
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
id: 'update',
|
|
529
|
+
type: 'state-update-global-state',
|
|
530
|
+
label: 'Update Favourites Global State',
|
|
531
|
+
config: { property: 'teleportProductFavourites', value: [] },
|
|
532
|
+
executionEnv: 'client',
|
|
533
|
+
stepNumber: 3,
|
|
534
|
+
},
|
|
535
|
+
],
|
|
536
|
+
edges: [
|
|
537
|
+
{ id: 'e1', source: 'extract', target: 'delete' },
|
|
538
|
+
{ id: 'e2', source: 'delete', target: 'update' },
|
|
539
|
+
],
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
interface BootedCustomNode {
|
|
544
|
+
run: (
|
|
545
|
+
outerContext: Record<string, unknown>,
|
|
546
|
+
parameters: Record<string, unknown>,
|
|
547
|
+
handlers: Record<string, unknown>
|
|
548
|
+
) => Promise<unknown>
|
|
549
|
+
segmentCalls: string[]
|
|
550
|
+
resolveSegment: () => void
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const bootCustomNode = (): BootedCustomNode => {
|
|
554
|
+
const plugin = new NextWorkflowProjectPlugin() as unknown as {
|
|
555
|
+
generateCustomNodesFile: (
|
|
556
|
+
nodes: Record<string, unknown>,
|
|
557
|
+
urls: Record<string, Record<string, string>>
|
|
558
|
+
) => string
|
|
559
|
+
}
|
|
560
|
+
const source = plugin.generateCustomNodesFile(customNodes, {
|
|
561
|
+
'cn-remove': { 'server-1': '/api/workflows/remove-seg-1' },
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// runtime-utils REPLACES module.exports wholesale, so read it back off the
|
|
565
|
+
// module object rather than the `exports` alias.
|
|
566
|
+
const utilsModule: { exports: Record<string, unknown> } = { exports: {} }
|
|
567
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
568
|
+
new Function('module', 'exports', generateSharedRuntimeUtilsCode())(
|
|
569
|
+
utilsModule,
|
|
570
|
+
utilsModule.exports
|
|
571
|
+
)
|
|
572
|
+
const utilsExports = utilsModule.exports
|
|
573
|
+
|
|
574
|
+
const segmentCalls: string[] = []
|
|
575
|
+
let releaseSegment: () => void = () => undefined
|
|
576
|
+
const segmentGate = new Promise<void>((resolve) => {
|
|
577
|
+
releaseSegment = resolve
|
|
578
|
+
})
|
|
579
|
+
const runtimeStub = {
|
|
580
|
+
findStreamingAINodes: () => ({}),
|
|
581
|
+
mergeServerResults: () => undefined,
|
|
582
|
+
callStreamingServerSegment: async () => ({}),
|
|
583
|
+
callServerSegment: async (url: string) => {
|
|
584
|
+
segmentCalls.push(url)
|
|
585
|
+
await segmentGate
|
|
586
|
+
return {}
|
|
587
|
+
},
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const requireStub = (id: string) =>
|
|
591
|
+
id === './runtime-utils' ? utilsExports : id === './runtime' ? runtimeStub : {}
|
|
592
|
+
|
|
593
|
+
const moduleStub: { exports: Record<string, unknown> } = { exports: {} }
|
|
594
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
595
|
+
new Function('module', 'exports', 'require', source)(
|
|
596
|
+
moduleStub,
|
|
597
|
+
moduleStub.exports,
|
|
598
|
+
requireStub
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
const registry = moduleStub.exports as Record<string, BootedCustomNode['run']>
|
|
602
|
+
return { run: registry['cn-remove'], segmentCalls, resolveSegment: releaseSegment }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
it('returns before the delete round trip completes, and returns the LAST awaited node', async () => {
|
|
606
|
+
const booted = bootCustomNode()
|
|
607
|
+
const handlers = {
|
|
608
|
+
'general-custom-js': async () => ({ entityId: 'e1' }),
|
|
609
|
+
'state-update-global-state': async () => ({
|
|
610
|
+
success: true,
|
|
611
|
+
property: 'teleportProductFavourites',
|
|
612
|
+
}),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Never released — if the custom node awaited the segment this would hang.
|
|
616
|
+
const result = (await booted.run({}, {}, handlers)) as Record<string, unknown>
|
|
617
|
+
|
|
618
|
+
expect(booted.segmentCalls).toEqual(['/api/workflows/remove-seg-1'])
|
|
619
|
+
// NOT the fire-and-forget node's null: the state update is the last node
|
|
620
|
+
// whose result the custom node may hand back to its caller.
|
|
621
|
+
expect(result).toEqual({ success: true, property: 'teleportProductFavourites' })
|
|
622
|
+
|
|
623
|
+
booted.resolveSegment()
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
it('publishes null under the non-awaited node so downstream reads are defined', async () => {
|
|
627
|
+
const booted = bootCustomNode()
|
|
628
|
+
const seen: Record<string, unknown> = {}
|
|
629
|
+
const handlers = {
|
|
630
|
+
'general-custom-js': async () => ({ entityId: 'e1' }),
|
|
631
|
+
'state-update-global-state': async (_config: unknown, context: Record<string, unknown>) => {
|
|
632
|
+
seen.delete = context.delete
|
|
633
|
+
seen.hasKey = Object.prototype.hasOwnProperty.call(context, 'delete')
|
|
634
|
+
return { success: true }
|
|
635
|
+
},
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await booted.run({}, {}, handlers)
|
|
639
|
+
expect(seen.hasKey).toBe(true)
|
|
640
|
+
expect(seen.delete).toBeNull()
|
|
641
|
+
|
|
642
|
+
booted.resolveSegment()
|
|
643
|
+
})
|
|
644
|
+
})
|