@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.
Files changed (112) hide show
  1. package/__tests__/_helpers/run-generated-middleware.ts +130 -0
  2. package/__tests__/auth-env-secret-preservation.test.ts +35 -0
  3. package/__tests__/auth-nextauth-url.test.ts +28 -0
  4. package/__tests__/auth-session-cost.test.ts +614 -0
  5. package/__tests__/custom-js-params-env-invariance.test.ts +134 -0
  6. package/__tests__/data-node-not-awaited.test.ts +644 -0
  7. package/__tests__/element-trigger-static-attr-replace.test.ts +99 -0
  8. package/__tests__/element-visible-trigger-scoping.test.ts +296 -0
  9. package/__tests__/form-submitted-trigger-context.test.ts +100 -0
  10. package/__tests__/middleware-guest-order-details.test.ts +258 -0
  11. package/__tests__/middleware-row-owned-pages.test.ts +32 -9
  12. package/__tests__/payment-charge-user-handler-serialization.test.ts +7 -5
  13. package/__tests__/payment-redirect-and-order-notification.test.ts +6 -4
  14. package/__tests__/raw-sql-param-binding.test.ts +82 -0
  15. package/__tests__/reserved-word-state-binding.test.ts +112 -0
  16. package/__tests__/strip-event-handler-attrs.test.ts +59 -0
  17. package/__tests__/workflow-auth-guard.test.ts +277 -0
  18. package/dist/cjs/api-route-generator.d.ts +3 -3
  19. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  20. package/dist/cjs/api-route-generator.js +136 -11
  21. package/dist/cjs/api-route-generator.js.map +1 -1
  22. package/dist/cjs/auth-generator.d.ts +67 -1
  23. package/dist/cjs/auth-generator.d.ts.map +1 -1
  24. package/dist/cjs/auth-generator.js +573 -31
  25. package/dist/cjs/auth-generator.js.map +1 -1
  26. package/dist/cjs/await-result.d.ts +31 -0
  27. package/dist/cjs/await-result.d.ts.map +1 -0
  28. package/dist/cjs/await-result.js +50 -0
  29. package/dist/cjs/await-result.js.map +1 -0
  30. package/dist/cjs/executor-generator.d.ts.map +1 -1
  31. package/dist/cjs/executor-generator.js +150 -1
  32. package/dist/cjs/executor-generator.js.map +1 -1
  33. package/dist/cjs/nodes/account/account-get-current.d.ts.map +1 -1
  34. package/dist/cjs/nodes/account/account-get-current.js +44 -11
  35. package/dist/cjs/nodes/account/account-get-current.js.map +1 -1
  36. package/dist/cjs/nodes/account/account-login.d.ts.map +1 -1
  37. package/dist/cjs/nodes/account/account-login.js +10 -1
  38. package/dist/cjs/nodes/account/account-login.js.map +1 -1
  39. package/dist/cjs/raw-sql-param-binding.d.ts.map +1 -1
  40. package/dist/cjs/raw-sql-param-binding.js +32 -0
  41. package/dist/cjs/raw-sql-param-binding.js.map +1 -1
  42. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  43. package/dist/cjs/segment-splitter.js +15 -7
  44. package/dist/cjs/segment-splitter.js.map +1 -1
  45. package/dist/cjs/trigger-generator.js +7 -1
  46. package/dist/cjs/trigger-generator.js.map +1 -1
  47. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  48. package/dist/cjs/workflow-auth-generator.d.ts +26 -0
  49. package/dist/cjs/workflow-auth-generator.d.ts.map +1 -0
  50. package/dist/cjs/workflow-auth-generator.js +166 -0
  51. package/dist/cjs/workflow-auth-generator.js.map +1 -0
  52. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  53. package/dist/cjs/workflow-component-plugin.js +124 -17
  54. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  55. package/dist/cjs/workflow-project-plugin.d.ts +2 -1
  56. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  57. package/dist/cjs/workflow-project-plugin.js +170 -20
  58. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  59. package/dist/esm/api-route-generator.d.ts +3 -3
  60. package/dist/esm/api-route-generator.d.ts.map +1 -1
  61. package/dist/esm/api-route-generator.js +136 -11
  62. package/dist/esm/api-route-generator.js.map +1 -1
  63. package/dist/esm/auth-generator.d.ts +67 -1
  64. package/dist/esm/auth-generator.d.ts.map +1 -1
  65. package/dist/esm/auth-generator.js +568 -30
  66. package/dist/esm/auth-generator.js.map +1 -1
  67. package/dist/esm/await-result.d.ts +31 -0
  68. package/dist/esm/await-result.d.ts.map +1 -0
  69. package/dist/esm/await-result.js +45 -0
  70. package/dist/esm/await-result.js.map +1 -0
  71. package/dist/esm/executor-generator.d.ts.map +1 -1
  72. package/dist/esm/executor-generator.js +150 -1
  73. package/dist/esm/executor-generator.js.map +1 -1
  74. package/dist/esm/nodes/account/account-get-current.d.ts.map +1 -1
  75. package/dist/esm/nodes/account/account-get-current.js +44 -11
  76. package/dist/esm/nodes/account/account-get-current.js.map +1 -1
  77. package/dist/esm/nodes/account/account-login.d.ts.map +1 -1
  78. package/dist/esm/nodes/account/account-login.js +10 -1
  79. package/dist/esm/nodes/account/account-login.js.map +1 -1
  80. package/dist/esm/raw-sql-param-binding.d.ts.map +1 -1
  81. package/dist/esm/raw-sql-param-binding.js +32 -0
  82. package/dist/esm/raw-sql-param-binding.js.map +1 -1
  83. package/dist/esm/segment-splitter.d.ts.map +1 -1
  84. package/dist/esm/segment-splitter.js +15 -7
  85. package/dist/esm/segment-splitter.js.map +1 -1
  86. package/dist/esm/trigger-generator.js +7 -1
  87. package/dist/esm/trigger-generator.js.map +1 -1
  88. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  89. package/dist/esm/workflow-auth-generator.d.ts +26 -0
  90. package/dist/esm/workflow-auth-generator.d.ts.map +1 -0
  91. package/dist/esm/workflow-auth-generator.js +161 -0
  92. package/dist/esm/workflow-auth-generator.js.map +1 -0
  93. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  94. package/dist/esm/workflow-component-plugin.js +125 -18
  95. package/dist/esm/workflow-component-plugin.js.map +1 -1
  96. package/dist/esm/workflow-project-plugin.d.ts +2 -1
  97. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  98. package/dist/esm/workflow-project-plugin.js +169 -20
  99. package/dist/esm/workflow-project-plugin.js.map +1 -1
  100. package/package.json +4 -4
  101. package/src/api-route-generator.ts +139 -11
  102. package/src/auth-generator.ts +600 -30
  103. package/src/await-result.ts +52 -0
  104. package/src/executor-generator.ts +150 -1
  105. package/src/nodes/account/account-get-current.ts +44 -9
  106. package/src/nodes/account/account-login.ts +12 -1
  107. package/src/raw-sql-param-binding.ts +33 -0
  108. package/src/segment-splitter.ts +15 -7
  109. package/src/trigger-generator.ts +7 -1
  110. package/src/workflow-auth-generator.ts +187 -0
  111. package/src/workflow-component-plugin.ts +146 -21
  112. 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
+ })