@teleporthq/teleport-plugin-next-workflows 0.43.58 → 0.43.60

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 (130) hide show
  1. package/__tests__/ai-chat-rag-sql-parameterization.test.ts +28 -8
  2. package/__tests__/ai-chat-tool-dispatch-segments.test.ts +110 -0
  3. package/__tests__/ai-custom-prompt-json-mode.test.ts +78 -0
  4. package/__tests__/ai-select-database-data-guard.test.ts +218 -0
  5. package/__tests__/ai-select-database-data-handler.test.ts +237 -0
  6. package/__tests__/cart-get-total-storefront-tax.test.ts +5 -0
  7. package/__tests__/client-config-redaction.test.ts +34 -2
  8. package/__tests__/controlled-select-plugin.test.ts +170 -0
  9. package/__tests__/controlled-select.ts +215 -0
  10. package/__tests__/parameterized-stock-chain.test.ts +453 -0
  11. package/__tests__/stock-decrement-audit.test.ts +5 -1
  12. package/__tests__/streaming-chunk-payload.test.ts +91 -0
  13. package/__tests__/streaming-on-end-env-filter.test.ts +2 -2
  14. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  15. package/dist/cjs/api-route-generator.js +2 -3
  16. package/dist/cjs/api-route-generator.js.map +1 -1
  17. package/dist/cjs/controlled-select.d.ts +23 -0
  18. package/dist/cjs/controlled-select.d.ts.map +1 -0
  19. package/dist/cjs/controlled-select.js +169 -0
  20. package/dist/cjs/controlled-select.js.map +1 -0
  21. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  22. package/dist/cjs/data-api-route-generator.js +4 -0
  23. package/dist/cjs/data-api-route-generator.js.map +1 -1
  24. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +23 -0
  25. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
  26. package/dist/cjs/ecommerce/parameterized-stock-chain.js +397 -0
  27. package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -0
  28. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +3 -0
  29. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  30. package/dist/cjs/ecommerce-customhandler-rewriter.js +41 -1
  31. package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
  32. package/dist/cjs/executor-generator.js +1 -1
  33. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  34. package/dist/cjs/invoice/api-routes-code.js +19 -5
  35. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  36. package/dist/cjs/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  37. package/dist/cjs/nodes/ai/ai-custom-prompt.js +4 -1
  38. package/dist/cjs/nodes/ai/ai-custom-prompt.js.map +1 -1
  39. package/dist/cjs/nodes/ai/ai-provider-utils.js +1 -1
  40. package/dist/cjs/nodes/ai/ai-select-database-data.d.ts +3 -0
  41. package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -0
  42. package/dist/cjs/nodes/ai/ai-select-database-data.js +141 -0
  43. package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -0
  44. package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts +18 -0
  45. package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
  46. package/dist/cjs/nodes/ai/ai-sql-select-guard.js +388 -0
  47. package/dist/cjs/nodes/ai/ai-sql-select-guard.js.map +1 -0
  48. package/dist/cjs/nodes/cart/cart-add-item.d.ts.map +1 -1
  49. package/dist/cjs/nodes/cart/cart-add-item.js +18 -0
  50. package/dist/cjs/nodes/cart/cart-add-item.js.map +1 -1
  51. package/dist/cjs/nodes/cart/cart-clear.d.ts.map +1 -1
  52. package/dist/cjs/nodes/cart/cart-clear.js +4 -0
  53. package/dist/cjs/nodes/cart/cart-clear.js.map +1 -1
  54. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  55. package/dist/cjs/nodes/cart/cart-get-total.js +36 -0
  56. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  57. package/dist/cjs/nodes/index.d.ts.map +1 -1
  58. package/dist/cjs/nodes/index.js +2 -1
  59. package/dist/cjs/nodes/index.js.map +1 -1
  60. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  61. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  62. package/dist/cjs/workflow-component-plugin.js +5 -0
  63. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  64. package/dist/esm/api-route-generator.d.ts.map +1 -1
  65. package/dist/esm/api-route-generator.js +2 -3
  66. package/dist/esm/api-route-generator.js.map +1 -1
  67. package/dist/esm/controlled-select.d.ts +23 -0
  68. package/dist/esm/controlled-select.d.ts.map +1 -0
  69. package/dist/esm/controlled-select.js +140 -0
  70. package/dist/esm/controlled-select.js.map +1 -0
  71. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  72. package/dist/esm/data-api-route-generator.js +4 -0
  73. package/dist/esm/data-api-route-generator.js.map +1 -1
  74. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +23 -0
  75. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
  76. package/dist/esm/ecommerce/parameterized-stock-chain.js +385 -0
  77. package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -0
  78. package/dist/esm/ecommerce-customhandler-rewriter.d.ts +3 -0
  79. package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  80. package/dist/esm/ecommerce-customhandler-rewriter.js +41 -1
  81. package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
  82. package/dist/esm/executor-generator.js +1 -1
  83. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  84. package/dist/esm/invoice/api-routes-code.js +19 -5
  85. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  86. package/dist/esm/nodes/ai/ai-custom-prompt.d.ts.map +1 -1
  87. package/dist/esm/nodes/ai/ai-custom-prompt.js +4 -1
  88. package/dist/esm/nodes/ai/ai-custom-prompt.js.map +1 -1
  89. package/dist/esm/nodes/ai/ai-provider-utils.js +1 -1
  90. package/dist/esm/nodes/ai/ai-select-database-data.d.ts +3 -0
  91. package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -0
  92. package/dist/esm/nodes/ai/ai-select-database-data.js +138 -0
  93. package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -0
  94. package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts +18 -0
  95. package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
  96. package/dist/esm/nodes/ai/ai-sql-select-guard.js +384 -0
  97. package/dist/esm/nodes/ai/ai-sql-select-guard.js.map +1 -0
  98. package/dist/esm/nodes/cart/cart-add-item.d.ts.map +1 -1
  99. package/dist/esm/nodes/cart/cart-add-item.js +18 -0
  100. package/dist/esm/nodes/cart/cart-add-item.js.map +1 -1
  101. package/dist/esm/nodes/cart/cart-clear.d.ts.map +1 -1
  102. package/dist/esm/nodes/cart/cart-clear.js +4 -0
  103. package/dist/esm/nodes/cart/cart-clear.js.map +1 -1
  104. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  105. package/dist/esm/nodes/cart/cart-get-total.js +36 -0
  106. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  107. package/dist/esm/nodes/index.d.ts.map +1 -1
  108. package/dist/esm/nodes/index.js +2 -1
  109. package/dist/esm/nodes/index.js.map +1 -1
  110. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  111. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  112. package/dist/esm/workflow-component-plugin.js +5 -0
  113. package/dist/esm/workflow-component-plugin.js.map +1 -1
  114. package/package.json +4 -4
  115. package/src/api-route-generator.ts +2 -3
  116. package/src/controlled-select.ts +167 -0
  117. package/src/data-api-route-generator.ts +4 -0
  118. package/src/ecommerce/parameterized-stock-chain.ts +449 -0
  119. package/src/ecommerce-customhandler-rewriter.ts +71 -3
  120. package/src/executor-generator.ts +1 -1
  121. package/src/invoice/api-routes-code.ts +19 -5
  122. package/src/nodes/ai/ai-custom-prompt.ts +4 -1
  123. package/src/nodes/ai/ai-provider-utils.ts +1 -1
  124. package/src/nodes/ai/ai-select-database-data.ts +162 -0
  125. package/src/nodes/ai/ai-sql-select-guard.ts +402 -0
  126. package/src/nodes/cart/cart-add-item.ts +18 -0
  127. package/src/nodes/cart/cart-clear.ts +4 -0
  128. package/src/nodes/cart/cart-get-total.ts +35 -0
  129. package/src/nodes/index.ts +2 -0
  130. package/src/workflow-component-plugin.ts +9 -0
@@ -22,14 +22,34 @@ import { generateSqlValidatorCode } from '../src/sql-validator'
22
22
  */
23
23
 
24
24
  const SEMANTIC_QUERY = [
25
- 'SELECT d.id, d.content, d.search_content, d.keywords,',
26
- ' 1 - (d.embedding_json::vector <=> q.query_vector) AS similarity',
27
- 'FROM teleport_ai_chat_documents d',
28
- "CROSS JOIN (SELECT NULLIF({{ queryEmbedding }}::text, '')::vector AS query_vector) q",
29
- 'WHERE d.embedding_json IS NOT NULL',
30
- ' AND q.query_vector IS NOT NULL',
31
- 'ORDER BY d.embedding_json::vector <=> q.query_vector ASC',
32
- 'LIMIT 6',
25
+ 'WITH corpus AS (',
26
+ ' SELECT count(*) AS total_docs,',
27
+ ' COALESCE(SUM(LENGTH(content)), 0) AS total_chars',
28
+ ' FROM teleport_ai_chat_documents',
29
+ ')',
30
+ 'SELECT ranked.id, ranked.content, ranked.search_content, ranked.keywords, ranked.similarity',
31
+ 'FROM (',
32
+ ' SELECT d.id, d.content, d.search_content, d.keywords,',
33
+ ' CASE',
34
+ ' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
35
+ ' ELSE 1 - (d.embedding_json::vector <=> q.query_vector)',
36
+ ' END AS similarity,',
37
+ ' row_number() OVER (',
38
+ ' ORDER BY (q.query_vector IS NULL OR d.embedding_json IS NULL) ASC,',
39
+ ' CASE',
40
+ ' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
41
+ ' ELSE d.embedding_json::vector <=> q.query_vector',
42
+ ' END ASC,',
43
+ ' d.id ASC',
44
+ ' ) AS rn',
45
+ ' FROM teleport_ai_chat_documents d',
46
+ " CROSS JOIN (SELECT NULLIF({{ queryEmbedding }}::text, '')::vector AS query_vector) q",
47
+ ') ranked',
48
+ 'CROSS JOIN corpus',
49
+ 'WHERE (corpus.total_docs <= 30 AND corpus.total_chars <= 24000)',
50
+ ' OR (ranked.similarity IS NOT NULL AND ranked.rn <= 6)',
51
+ 'ORDER BY ranked.rn ASC',
52
+ 'LIMIT 30',
33
53
  ].join('\n')
34
54
 
35
55
  const LEXICAL_QUERY = [
@@ -0,0 +1,110 @@
1
+ import { splitIntoSegments } from '../src/segment-splitter'
2
+ import type { UIDLWorkflow, UIDLWorkflowNode, UIDLWorkflowEdge } from '@teleporthq/teleport-types'
3
+
4
+ /**
5
+ * The AI Assistant Chat's action layer stands or falls on ONE property of the
6
+ * generated app: the tool must run BEFORE the answer is written, so the reply
7
+ * can report truthfully what happened.
8
+ *
9
+ * The tool runs inside a `general-custom-node`, which the splitter pins to the
10
+ * CLIENT, while both AI calls are SERVER nodes. So the walk has to produce
11
+ * `[server: decide] [client: run] [server: answer]`.
12
+ *
13
+ * The chat used to express this with two plain edges out of a parse node — one
14
+ * into the tool chain, one into the answer — and relied on the answer's PROMPT
15
+ * referencing the tool's summary to force the ordering, because
16
+ * `getTopologicalOrder` turns a `workflowContext` reference into a dependency
17
+ * edge. That worked, but only by accident of the data flow: remove the
18
+ * reference and the answer was written before the tool ran.
19
+ *
20
+ * The dispatch now lives inside its own custom node, so the ordering comes from
21
+ * a real EDGE. This test pins both halves: the three-segment shape, and that it
22
+ * survives even with no prompt reference at all.
23
+ */
24
+
25
+ const ctx = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
26
+
27
+ const node = (
28
+ id: string,
29
+ type: string,
30
+ stepNumber: number,
31
+ config: Record<string, unknown> = {}
32
+ ): UIDLWorkflowNode => ({ id, type, label: id, config, stepNumber } as unknown as UIDLWorkflowNode)
33
+
34
+ const edge = (source: string, target: string, sourceHandle?: string): UIDLWorkflowEdge =>
35
+ ({ id: `${source}->${target}`, source, target, sourceHandle } as unknown as UIDLWorkflowEdge)
36
+
37
+ /** The chat's shape from the merge script onward. */
38
+ function buildChatWorkflow(options: { answerReferencesOutcome: boolean }): UIDLWorkflow {
39
+ const nodes: UIDLWorkflowNode[] = [
40
+ node('merge', 'general-custom-js', 1, { context: 'server', code: 'x' }),
41
+ node('decide', 'ai-custom-prompt', 2, {
42
+ jsonMode: true,
43
+ prompt: ['ctx:', ctx('merge', ['merge', 'context'])],
44
+ }),
45
+ node('run', 'general-custom-node', 3, {
46
+ customNodeId: 'custom_run_action',
47
+ parameters: [{ key: 'decisionText', value: ctx('decide', ['decide', 'response']) }],
48
+ }),
49
+ node('answer', 'ai-custom-prompt', 4, {
50
+ streaming: true,
51
+ prompt: [
52
+ 'Context:',
53
+ ctx('merge', ['merge', 'context']),
54
+ ...(options.answerReferencesOutcome
55
+ ? ['\nOutcome: ', ctx('run', ['run', 'outcomeText'])]
56
+ : []),
57
+ ],
58
+ }),
59
+ ]
60
+
61
+ const edges: UIDLWorkflowEdge[] = [
62
+ edge('merge', 'decide'),
63
+ edge('decide', 'run'),
64
+ edge('run', 'answer'),
65
+ ]
66
+
67
+ return { name: 'send-chat-message', nodes, edges, trigger: { type: 'manual' } } as UIDLWorkflow
68
+ }
69
+
70
+ const segmentOf = (segments: ReturnType<typeof splitIntoSegments>, nodeId: string): number =>
71
+ segments.findIndex((segment) => segment.nodes.some((entry) => entry.id === nodeId))
72
+
73
+ describe('AI chat tool dispatch — segment ordering', () => {
74
+ const segments = splitIntoSegments(buildChatWorkflow({ answerReferencesOutcome: true }))
75
+
76
+ it('runs the action on the CLIENT, where general-custom-node is dispatched', () => {
77
+ // The server handler for general-custom-node is a stub: placed in a server
78
+ // segment the nested custom node never runs and downstream references
79
+ // resolve to the raw marker object.
80
+ expect(segments[segmentOf(segments, 'run')].env).toBe('client')
81
+ })
82
+
83
+ it('keeps both AI calls on the server', () => {
84
+ expect(segments[segmentOf(segments, 'decide')].env).toBe('server')
85
+ expect(segments[segmentOf(segments, 'answer')].env).toBe('server')
86
+ })
87
+
88
+ it('orders the answer AFTER the action', () => {
89
+ const answerSegment = segmentOf(segments, 'answer')
90
+ expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'decide'))
91
+ expect(answerSegment).toBeGreaterThan(segmentOf(segments, 'run'))
92
+ })
93
+
94
+ it('produces exactly the three-segment shape the design depends on', () => {
95
+ expect(segments.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
96
+ })
97
+
98
+ it('holds the ordering on the EDGE, not on the prompt reference', () => {
99
+ // The regression this replaces: with the old two-edges-out-of-parse shape,
100
+ // dropping the prompt reference let the answer overtake the tool. A real
101
+ // edge cannot be overtaken, so the shape is identical either way.
102
+ const withoutReference = splitIntoSegments(
103
+ buildChatWorkflow({ answerReferencesOutcome: false })
104
+ )
105
+ expect(withoutReference.map((segment) => segment.env)).toEqual(['server', 'client', 'server'])
106
+ expect(segmentOf(withoutReference, 'answer')).toBeGreaterThan(
107
+ segmentOf(withoutReference, 'run')
108
+ )
109
+ })
110
+ })
@@ -0,0 +1,78 @@
1
+ import { aiCustomPrompt } from '../src/nodes/ai/ai-custom-prompt'
2
+
3
+ /**
4
+ * `ai-custom-prompt` used to hard-code `jsonMode: false`, so a node whose only
5
+ * job is to emit a machine-readable decision (the AI Assistant Chat's tool
6
+ * router) had no way to ask the provider for a JSON-only reply. The flag is now
7
+ * forwarded from the node config.
8
+ *
9
+ * The generated provider utils are emitted as
10
+ * `var __ai_callProvider = typeof __ai_callProvider !== 'undefined' ? … : <def>`,
11
+ * so defining the name ahead of the handler source substitutes a spy for the
12
+ * real provider call.
13
+ */
14
+ type ProviderCall = { jsonMode?: boolean; systemMessage?: string; userMessage?: string }
15
+ type PromptHandler = (
16
+ config: Record<string, unknown>,
17
+ context: Record<string, unknown>,
18
+ streamCallback?: unknown
19
+ ) => Promise<Record<string, unknown>>
20
+
21
+ function loadHandlerWithProviderSpy(): { handler: PromptHandler; calls: ProviderCall[] } {
22
+ const calls: ProviderCall[] = []
23
+ const source = aiCustomPrompt.generateHandler()
24
+ // eslint-disable-next-line no-new-func
25
+ const factory = new Function(
26
+ '__calls',
27
+ [
28
+ 'var __ai_callProvider = function (params) {',
29
+ ' __calls.push(params);',
30
+ ' return Promise.resolve({ content: \'{"action":"none"}\', usage: { total_tokens: 3 } });',
31
+ '};',
32
+ source,
33
+ 'return ai_custom_prompt;',
34
+ ].join('\n')
35
+ )
36
+ return { handler: factory(calls) as PromptHandler, calls }
37
+ }
38
+
39
+ describe('ai-custom-prompt jsonMode passthrough', () => {
40
+ it('asks the provider for JSON when the node config sets jsonMode', async () => {
41
+ const { handler, calls } = loadHandlerWithProviderSpy()
42
+
43
+ const result = await handler({ prompt: 'Decide.', token: 'sk-test', jsonMode: true }, {})
44
+
45
+ expect(calls).toHaveLength(1)
46
+ expect(calls[0].jsonMode).toBe(true)
47
+ expect(result.response).toBe('{"action":"none"}')
48
+ })
49
+
50
+ it('leaves JSON mode off for an ordinary prompt node', async () => {
51
+ const { handler, calls } = loadHandlerWithProviderSpy()
52
+
53
+ await handler({ prompt: 'Write a haiku.', token: 'sk-test' }, {})
54
+
55
+ expect(calls[0].jsonMode).toBe(false)
56
+ })
57
+
58
+ it('treats any non-true value as off, so a stale string config cannot enable it', async () => {
59
+ const { handler, calls } = loadHandlerWithProviderSpy()
60
+
61
+ await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: 'true' }, {})
62
+
63
+ expect(calls[0].jsonMode).toBe(false)
64
+ })
65
+
66
+ it('never sends jsonMode down the streaming path', async () => {
67
+ const { handler, calls } = loadHandlerWithProviderSpy()
68
+ const chunks: string[] = []
69
+
70
+ // No stream callback => the streaming branch is skipped; with one, the
71
+ // handler must not reach __ai_callProvider at all.
72
+ await handler({ prompt: 'Hi.', token: 'sk-test', jsonMode: true, streaming: true }, {}, () =>
73
+ chunks.push('x')
74
+ )
75
+
76
+ expect(calls).toHaveLength(0)
77
+ })
78
+ })
@@ -0,0 +1,218 @@
1
+ import { generateAiSqlSelectGuard } from '../src/nodes/ai/ai-sql-select-guard'
2
+
3
+ /**
4
+ * The guard is the ONLY thing standing between an AI model's output and the
5
+ * database: the model can be prompt-injected into producing anything, so a
6
+ * miss here is a write/exfiltration primitive, and an over-match breaks the
7
+ * legitimate aggregate queries the node exists for (MIN/MAX/CASE…END).
8
+ * Exercised exactly as the generated app defines it — by evaluating the
9
+ * emitted source.
10
+ */
11
+ function loadGuard() {
12
+ // eslint-disable-next-line no-new-func
13
+ return new Function(
14
+ `${generateAiSqlSelectGuard()}
15
+ return {
16
+ validate: __aisql_validateSelectQuery,
17
+ enforceLimit: __aisql_enforceLimit,
18
+ buildSystemPrompt: __aisql_buildSystemPrompt,
19
+ };`
20
+ )() as {
21
+ validate: (sql: string, allowedTables: string[]) => { valid: boolean; reason?: string }
22
+ enforceLimit: (sql: string, maxRows: number) => string
23
+ buildSystemPrompt: (tableSchemas: unknown, allowedTables: string[], maxRows: number) => string
24
+ }
25
+ }
26
+
27
+ const { validate, enforceLimit, buildSystemPrompt } = loadGuard()
28
+ const ALLOWED = ['teleport_products', 'teleport_blog_posts']
29
+
30
+ describe('accepts the queries the node exists for', () => {
31
+ it('plain SELECT over an allowed table', () => {
32
+ expect(
33
+ validate('SELECT "id", "name", "price" FROM "teleport_products" LIMIT 10', ALLOWED)
34
+ ).toEqual({
35
+ valid: true,
36
+ })
37
+ })
38
+
39
+ it('aggregates and superlatives (MIN/MAX/ORDER BY … LIMIT 1)', () => {
40
+ expect(
41
+ validate(
42
+ 'SELECT "name", "price" FROM "teleport_products" ORDER BY "price" DESC LIMIT 1',
43
+ ALLOWED
44
+ ).valid
45
+ ).toBe(true)
46
+ expect(validate('SELECT MAX("price") FROM "teleport_products"', ALLOWED).valid).toBe(true)
47
+ expect(validate('SELECT COUNT(*) FROM "teleport_blog_posts";', ALLOWED).valid).toBe(true)
48
+ })
49
+
50
+ it('CASE … END (the forms-worker validator regression)', () => {
51
+ expect(
52
+ validate(
53
+ `SELECT "name", CASE WHEN "price" > 100 THEN 'premium' ELSE 'standard' END AS tier FROM "teleport_products"`,
54
+ ALLOWED
55
+ ).valid
56
+ ).toBe(true)
57
+ })
58
+
59
+ it('WITH … SELECT over allowed tables, CTE referenced by name', () => {
60
+ expect(
61
+ validate(
62
+ 'WITH ranked AS (SELECT "name", "price" FROM "teleport_products") SELECT * FROM ranked ORDER BY "price" ASC LIMIT 1',
63
+ ALLOWED
64
+ ).valid
65
+ ).toBe(true)
66
+ })
67
+
68
+ it('JOIN with an alias between allowed tables', () => {
69
+ expect(
70
+ validate(
71
+ 'SELECT p."name" FROM "teleport_products" p JOIN "teleport_blog_posts" b ON b."id" = p."id"',
72
+ ALLOWED
73
+ ).valid
74
+ ).toBe(true)
75
+ })
76
+
77
+ it('set-returning functions in FROM are not table refs', () => {
78
+ expect(
79
+ validate(
80
+ `SELECT kv.key FROM "teleport_products" p, jsonb_each_text(p."metadata") kv`,
81
+ ALLOWED
82
+ ).valid
83
+ ).toBe(true)
84
+ })
85
+
86
+ it('keywords inside string literals do not false-positive', () => {
87
+ expect(
88
+ validate(`SELECT "name" FROM "teleport_products" WHERE "status" = 'deleted'`, ALLOWED).valid
89
+ ).toBe(true)
90
+ expect(
91
+ validate(
92
+ `SELECT "name" FROM "teleport_products" WHERE "description" = 'DROP by the store'`,
93
+ ALLOWED
94
+ ).valid
95
+ ).toBe(true)
96
+ })
97
+ })
98
+
99
+ describe('rejects everything that is not a single read-only SELECT', () => {
100
+ const rejected = (sql: string, tables: string[] = ALLOWED) =>
101
+ validate(sql, tables).valid === false
102
+
103
+ it('writes and DDL', () => {
104
+ expect(rejected(`INSERT INTO "teleport_products" ("name") VALUES ('x')`)).toBe(true)
105
+ expect(rejected(`UPDATE "teleport_products" SET "price" = 0`)).toBe(true)
106
+ expect(rejected(`DELETE FROM "teleport_products"`)).toBe(true)
107
+ expect(rejected(`DROP TABLE "teleport_products"`)).toBe(true)
108
+ expect(rejected(`SELECT * INTO backup FROM "teleport_products"`)).toBe(true)
109
+ })
110
+
111
+ it('stacked statements and comments', () => {
112
+ expect(rejected(`SELECT 1; DELETE FROM "teleport_products"`)).toBe(true)
113
+ expect(rejected(`SELECT "name" FROM "teleport_products" -- hidden`)).toBe(true)
114
+ expect(rejected(`SELECT /* smuggle */ "name" FROM "teleport_products"`)).toBe(true)
115
+ })
116
+
117
+ it('system catalogs and pg_* objects', () => {
118
+ expect(rejected(`SELECT * FROM information_schema.tables`)).toBe(true)
119
+ expect(rejected(`SELECT * FROM pg_catalog.pg_tables`)).toBe(true)
120
+ expect(rejected(`SELECT * FROM pg_stat_activity`)).toBe(true)
121
+ })
122
+
123
+ it('tables outside the allowlist, in FROM, JOIN, and subqueries', () => {
124
+ expect(rejected(`SELECT * FROM "users"`)).toBe(true)
125
+ expect(rejected(`SELECT * FROM "teleport_products" JOIN "users" u ON u."id" = 1`)).toBe(true)
126
+ expect(
127
+ rejected(`SELECT * FROM "teleport_products" WHERE "id" IN (SELECT "id" FROM "users")`)
128
+ ).toBe(true)
129
+ expect(rejected(`SELECT * FROM otherschema."teleport_products"`)).toBe(true)
130
+ })
131
+
132
+ it('a CTE cannot launder a non-allowed table', () => {
133
+ expect(rejected(`WITH x AS (SELECT * FROM "users") SELECT * FROM x`)).toBe(true)
134
+ })
135
+
136
+ it('timing/file functions and locks', () => {
137
+ expect(rejected(`SELECT pg_sleep(10)`)).toBe(true)
138
+ expect(rejected(`SELECT current_setting('server_version')`)).toBe(true)
139
+ expect(rejected(`SELECT "name" FROM "teleport_products" FOR SHARE`)).toBe(true)
140
+ // FOR UPDATE falls to the UPDATE keyword rule
141
+ expect(rejected(`SELECT "name" FROM "teleport_products" FOR UPDATE`)).toBe(true)
142
+ })
143
+
144
+ it('transaction control and session state', () => {
145
+ expect(rejected(`BEGIN`)).toBe(true)
146
+ expect(rejected(`SET search_path TO public`)).toBe(true)
147
+ })
148
+
149
+ it('degenerate inputs', () => {
150
+ expect(rejected('')).toBe(true)
151
+ expect(rejected(' ')).toBe(true)
152
+ expect(rejected('SELECT 1'.padEnd(10001, '1'))).toBe(true)
153
+ expect(validate(null as unknown as string, ALLOWED).valid).toBe(false)
154
+ })
155
+ })
156
+
157
+ describe('enforceLimit', () => {
158
+ it('appends a LIMIT when the statement has none', () => {
159
+ expect(enforceLimit('SELECT * FROM "teleport_products"', 100)).toBe(
160
+ 'SELECT * FROM "teleport_products" LIMIT 100'
161
+ )
162
+ })
163
+
164
+ it('clamps an oversized trailing LIMIT and keeps a compliant one', () => {
165
+ expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5000', 100)).toBe(
166
+ 'SELECT * FROM "teleport_products" LIMIT 100'
167
+ )
168
+ expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5', 100)).toBe(
169
+ 'SELECT * FROM "teleport_products" LIMIT 5'
170
+ )
171
+ })
172
+
173
+ it('a subquery LIMIT does not satisfy the outer statement', () => {
174
+ const sql = 'SELECT * FROM (SELECT * FROM "teleport_products" LIMIT 5) t WHERE t."price" > 0'
175
+ expect(enforceLimit(sql, 100)).toBe(sql + ' LIMIT 100')
176
+ })
177
+
178
+ it('strips a trailing semicolon and re-clamps maxRows itself', () => {
179
+ expect(enforceLimit('SELECT 1;', 999999)).toBe('SELECT 1 LIMIT 1000')
180
+ expect(enforceLimit('SELECT 1', 0)).toBe('SELECT 1 LIMIT 100')
181
+ })
182
+ })
183
+
184
+ describe('buildSystemPrompt', () => {
185
+ it('renders baked schemas and falls back for undescribed tables', () => {
186
+ const prompt = buildSystemPrompt(
187
+ [
188
+ {
189
+ table: 'teleport_products',
190
+ columns: [
191
+ { name: 'id', type: 'uuid', nullable: false },
192
+ { name: 'price', type: 'numeric' },
193
+ ],
194
+ },
195
+ ],
196
+ ALLOWED,
197
+ 50
198
+ )
199
+ expect(prompt).toContain('TABLE "teleport_products" ("id" uuid NOT NULL, "price" numeric)')
200
+ expect(prompt).toContain('TABLE "teleport_blog_posts" (column names unknown')
201
+ expect(prompt).toContain('LIMIT of at most 50')
202
+ expect(prompt).toContain('needsQuery')
203
+ })
204
+
205
+ it('teaches the SQL habits the store chat needed in practice', () => {
206
+ const prompt = buildSystemPrompt([], ALLOWED, 50)
207
+ // "what price does the 054 product have?" — partial name, possibly living
208
+ // in a language-suffixed column.
209
+ expect(prompt).toContain('ILIKE')
210
+ expect(prompt).toContain('language-suffixed variants')
211
+ // "most expensive product" with several products at the same top price —
212
+ // ORDER BY + LIMIT 1 silently returned only one of them.
213
+ expect(prompt).toContain('MAX()/MIN() subquery')
214
+ expect(prompt).toContain('never ORDER BY with LIMIT 1')
215
+ // Rows must arrive identifiable and linkable.
216
+ expect(prompt).toContain('name/title/slug-like columns')
217
+ })
218
+ })
@@ -0,0 +1,237 @@
1
+ import { aiSelectDatabaseData } from '../src/nodes/ai/ai-select-database-data'
2
+ import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
3
+
4
+ /**
5
+ * The handler's result is serialized straight to the visitor's browser, so the
6
+ * contract under test is as much about what it must NEVER contain (the
7
+ * generated SQL, schema text, database error messages) as about what it
8
+ * returns. The AI call and fetch are mocked by pre-defining the guarded
9
+ * globals — the emitted `var X = typeof X !== 'undefined' ? X : …` pattern
10
+ * keeps whatever is already in scope.
11
+ */
12
+
13
+ interface MockState {
14
+ aiResponses: string[]
15
+ aiCalls: Array<{ systemMessage: string; userMessage: string; jsonMode: boolean }>
16
+ fetchCalls: Array<{ url: string; body: { query: string; params: unknown[] } }>
17
+ fetchResponse: { ok: boolean; json: unknown }
18
+ }
19
+
20
+ function loadHandler(state: MockState) {
21
+ const mocks = `
22
+ var __ai_callProvider = async function (params) {
23
+ state.aiCalls.push({ systemMessage: params.systemMessage, userMessage: params.userMessage, jsonMode: params.jsonMode });
24
+ var next = state.aiResponses.shift();
25
+ if (next === '__throw__') { throw new Error('provider exploded'); }
26
+ return { content: next };
27
+ };
28
+ `
29
+ const source = aiSelectDatabaseData.generateServerHandler!()
30
+ const fetchMock = async (url: string, init: { body: string }) => {
31
+ state.fetchCalls.push({ url, body: JSON.parse(init.body) })
32
+ return {
33
+ ok: state.fetchResponse.ok,
34
+ json: async () => state.fetchResponse.json,
35
+ }
36
+ }
37
+ // eslint-disable-next-line no-new-func
38
+ return new Function('state', 'fetch', `${mocks}\n${source}\nreturn ai_select_database_data;`)(
39
+ state,
40
+ fetchMock
41
+ ) as (config: unknown, context: Record<string, unknown>) => Promise<any>
42
+ }
43
+
44
+ function makeState(overrides: Partial<MockState> = {}): MockState {
45
+ return {
46
+ aiResponses: ['{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\""}'],
47
+ aiCalls: [],
48
+ fetchCalls: [],
49
+ fetchResponse: { ok: true, json: { rows: [{ id: '1' }, { id: '2' }] } },
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ const BASE_CONFIG = {
55
+ dataSourceId: 'ds1',
56
+ allowedTables: ['teleport_products'],
57
+ prompt: 'What is the cheapest product?',
58
+ model: 'gpt-4o',
59
+ token: 'sk-test',
60
+ tableSchemas: [
61
+ { table: 'teleport_products', columns: [{ name: 'id', type: 'uuid', nullable: false }] },
62
+ ],
63
+ }
64
+
65
+ function loadIsFatalNodeResult(): (result: unknown) => boolean {
66
+ const utilsModule = { exports: {} as { isFatalNodeResult: (result: unknown) => boolean } }
67
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
68
+ new Function('module', 'exports', 'require', generateSharedRuntimeUtilsCode())(
69
+ utilsModule,
70
+ utilsModule.exports,
71
+ () => ({})
72
+ )
73
+ return utilsModule.exports.isFatalNodeResult
74
+ }
75
+
76
+ describe('happy path', () => {
77
+ it('generates, validates, executes, and returns only { executed, rowCount, rows, truncated }', async () => {
78
+ const state = makeState()
79
+ const handler = loadHandler(state)
80
+ const result = await handler(BASE_CONFIG, {})
81
+
82
+ expect(result).toEqual({
83
+ executed: true,
84
+ rowCount: 2,
85
+ rows: [{ id: '1' }, { id: '2' }],
86
+ // Two rows under a cap of 50: nothing was cut off, so a caller may
87
+ // report this as a complete result.
88
+ truncated: false,
89
+ })
90
+ expect(state.aiCalls).toHaveLength(1)
91
+ expect(state.aiCalls[0].jsonMode).toBe(true)
92
+ expect(state.aiCalls[0].systemMessage).toContain('TABLE "teleport_products"')
93
+ expect(state.fetchCalls).toHaveLength(1)
94
+ expect(state.fetchCalls[0].url).toBe('/api/data/ds1/raw-query')
95
+ // The executed SQL carries the enforced LIMIT
96
+ expect(state.fetchCalls[0].body.query).toBe('SELECT "id" FROM "teleport_products" LIMIT 100')
97
+ })
98
+
99
+ it('respects the AI deciding no query is needed, without touching the database', async () => {
100
+ const state = makeState({ aiResponses: ['{"needsQuery": false, "query": null}'] })
101
+ const handler = loadHandler(state)
102
+ const result = await handler(BASE_CONFIG, {})
103
+
104
+ expect(result).toEqual({ executed: false, rows: [], rowCount: 0, truncated: false })
105
+ expect(state.fetchCalls).toHaveLength(0)
106
+ })
107
+
108
+ it('retries once with feedback when the first SQL is rejected', async () => {
109
+ const state = makeState({
110
+ aiResponses: [
111
+ '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}',
112
+ '{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\" LIMIT 5"}',
113
+ ],
114
+ })
115
+ const handler = loadHandler(state)
116
+ const result = await handler(BASE_CONFIG, {})
117
+
118
+ expect(result.executed).toBe(true)
119
+ expect(state.aiCalls).toHaveLength(2)
120
+ expect(state.aiCalls[1].userMessage).toContain('was rejected')
121
+ })
122
+ })
123
+
124
+ describe('failure contract — nothing sensitive ever leaves the handler', () => {
125
+ const flatten = (value: unknown): string => JSON.stringify(value)
126
+
127
+ it('rejected SQL on both attempts fails closed with a fixed code', async () => {
128
+ const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
129
+ const state = makeState({ aiResponses: [bad, bad] })
130
+ const handler = loadHandler(state)
131
+ const result = await handler(BASE_CONFIG, {})
132
+
133
+ expect(result).toEqual({
134
+ error: true,
135
+ message: 'AI database query failed (sql_rejected)',
136
+ code: 'sql_rejected',
137
+ })
138
+ expect(flatten(result)).not.toContain('DELETE')
139
+ expect(flatten(result)).not.toContain('teleport_products')
140
+ })
141
+
142
+ it('never forwards the data route error body (pg errors quote the SQL)', async () => {
143
+ const state = makeState({
144
+ fetchResponse: {
145
+ ok: false,
146
+ json: { error: 'syntax error at or near "SELECT \\"id\\" FROM teleport_products"' },
147
+ },
148
+ })
149
+ const handler = loadHandler(state)
150
+ const result = await handler(BASE_CONFIG, {})
151
+
152
+ expect(result.code).toBe('query_failed')
153
+ expect(flatten(result)).not.toContain('syntax error')
154
+ expect(flatten(result)).not.toContain('SELECT')
155
+ })
156
+
157
+ it('optional: true converts every failure into skipped data', async () => {
158
+ const state = makeState({ aiResponses: ['__throw__'] })
159
+ const handler = loadHandler(state)
160
+ const result = await handler({ ...BASE_CONFIG, optional: true }, {})
161
+
162
+ expect(result).toEqual({
163
+ executed: false,
164
+ rows: [],
165
+ rowCount: 0,
166
+ truncated: false,
167
+ skipped: true,
168
+ skipReason: 'provider_error',
169
+ })
170
+ })
171
+
172
+ it('missing configuration fails before any AI call', async () => {
173
+ const state = makeState()
174
+ const handler = loadHandler(state)
175
+ const result = await handler({ ...BASE_CONFIG, allowedTables: [] }, {})
176
+
177
+ expect(result.code).toBe('missing_configuration')
178
+ expect(state.aiCalls).toHaveLength(0)
179
+ })
180
+
181
+ it('the executor treats default failures as fatal and optional ones as data', async () => {
182
+ const isFatalNodeResult = loadIsFatalNodeResult()
183
+ const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
184
+
185
+ const fatalState = makeState({ aiResponses: [bad, bad] })
186
+ const fatal = await loadHandler(fatalState)(BASE_CONFIG, {})
187
+ const optionalState = makeState({ aiResponses: [bad, bad] })
188
+ const optional = await loadHandler(optionalState)({ ...BASE_CONFIG, optional: true }, {})
189
+
190
+ expect(isFatalNodeResult(fatal)).toBe(true)
191
+ expect(isFatalNodeResult(optional)).toBe(false)
192
+ })
193
+ })
194
+
195
+ describe('registration', () => {
196
+ it('is a server node so its config (schemas, allowlist) is redacted from client bundles', () => {
197
+ expect(aiSelectDatabaseData.executionEnv).toBe('server')
198
+ })
199
+ })
200
+
201
+ describe('telling a capped result apart from a complete one', () => {
202
+ it('flags a full page of rows as truncated', async () => {
203
+ // The statement is capped, so a result that fills the cap is one page of a
204
+ // larger answer. Reporting its length as a total is what made the chat say
205
+ // "there are 50 products" for a catalogue of 220.
206
+ const rows = Array.from({ length: 50 }, (_, index) => ({ id: String(index) }))
207
+ const state = makeState({ fetchResponse: { ok: true, json: { rows } } })
208
+ const handler = loadHandler(state)
209
+
210
+ const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
211
+ truncated: boolean
212
+ rowCount: number
213
+ }
214
+
215
+ expect(result.truncated).toBe(true)
216
+ expect(result.rowCount).toBe(50)
217
+ })
218
+
219
+ it('does not flag an aggregate answer as truncated', async () => {
220
+ const state = makeState({
221
+ aiResponses: [
222
+ '{"needsQuery": true, "query": "SELECT COUNT(*) AS \\"total_count\\" FROM \\"teleport_products\\""}',
223
+ ],
224
+ fetchResponse: { ok: true, json: { rows: [{ total_count: 223 }] } },
225
+ })
226
+ const handler = loadHandler(state)
227
+
228
+ const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
229
+ truncated: boolean
230
+ rows: unknown[]
231
+ }
232
+
233
+ // One row carrying the real total — the only shape that can state one.
234
+ expect(result.truncated).toBe(false)
235
+ expect(result.rows).toEqual([{ total_count: 223 }])
236
+ })
237
+ })