@teleporthq/teleport-plugin-next-workflows 0.43.55 → 0.43.57

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 (127) hide show
  1. package/__tests__/_helpers/abort-controller-stub.ts +64 -0
  2. package/__tests__/account-refresh-session.test.ts +110 -0
  3. package/__tests__/cache-nodes.test.ts +137 -0
  4. package/__tests__/cart-get-total-delivery-flags.test.ts +86 -0
  5. package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
  6. package/__tests__/invoice-delivery-fee.test.ts +155 -0
  7. package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
  8. package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
  9. package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
  10. package/__tests__/stock-decrement-audit.test.ts +77 -0
  11. package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
  12. package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
  13. package/dist/cjs/ecommerce/stock-decrement.js +72 -6
  14. package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
  15. package/dist/cjs/executor-generator.d.ts.map +1 -1
  16. package/dist/cjs/executor-generator.js +93 -15
  17. package/dist/cjs/executor-generator.js.map +1 -1
  18. package/dist/cjs/index.d.ts +1 -0
  19. package/dist/cjs/index.d.ts.map +1 -1
  20. package/dist/cjs/index.js +3 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
  23. package/dist/cjs/invoice/api-routes-code.js +31 -2
  24. package/dist/cjs/invoice/api-routes-code.js.map +1 -1
  25. package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
  26. package/dist/cjs/invoice/invoice-html-code.js +25 -4
  27. package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
  28. package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
  29. package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
  30. package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
  31. package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
  32. package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
  33. package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
  34. package/dist/cjs/nodes/cache/cache-get.js +39 -0
  35. package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
  36. package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
  37. package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
  38. package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
  39. package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
  40. package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
  41. package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
  42. package/dist/cjs/nodes/cache/cache-set.js +71 -0
  43. package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
  44. package/dist/cjs/nodes/cache/store.d.ts +29 -0
  45. package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
  46. package/dist/cjs/nodes/cache/store.js +83 -0
  47. package/dist/cjs/nodes/cache/store.js.map +1 -0
  48. package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
  49. package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
  50. package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
  51. package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  52. package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
  53. package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
  54. package/dist/cjs/nodes/index.d.ts.map +1 -1
  55. package/dist/cjs/nodes/index.js +5 -1
  56. package/dist/cjs/nodes/index.js.map +1 -1
  57. package/dist/cjs/runtime-storage-generator.d.ts +18 -0
  58. package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
  59. package/dist/cjs/runtime-storage-generator.js +63 -3
  60. package/dist/cjs/runtime-storage-generator.js.map +1 -1
  61. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  62. package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
  63. package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
  64. package/dist/esm/ecommerce/stock-decrement.js +72 -6
  65. package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
  66. package/dist/esm/executor-generator.d.ts.map +1 -1
  67. package/dist/esm/executor-generator.js +93 -15
  68. package/dist/esm/executor-generator.js.map +1 -1
  69. package/dist/esm/index.d.ts +1 -0
  70. package/dist/esm/index.d.ts.map +1 -1
  71. package/dist/esm/index.js +1 -0
  72. package/dist/esm/index.js.map +1 -1
  73. package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
  74. package/dist/esm/invoice/api-routes-code.js +31 -2
  75. package/dist/esm/invoice/api-routes-code.js.map +1 -1
  76. package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
  77. package/dist/esm/invoice/invoice-html-code.js +25 -4
  78. package/dist/esm/invoice/invoice-html-code.js.map +1 -1
  79. package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
  80. package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
  81. package/dist/esm/nodes/account/account-refresh-session.js +93 -0
  82. package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
  83. package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
  84. package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
  85. package/dist/esm/nodes/cache/cache-get.js +36 -0
  86. package/dist/esm/nodes/cache/cache-get.js.map +1 -0
  87. package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
  88. package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
  89. package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
  90. package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
  91. package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
  92. package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
  93. package/dist/esm/nodes/cache/cache-set.js +68 -0
  94. package/dist/esm/nodes/cache/cache-set.js.map +1 -0
  95. package/dist/esm/nodes/cache/store.d.ts +29 -0
  96. package/dist/esm/nodes/cache/store.d.ts.map +1 -0
  97. package/dist/esm/nodes/cache/store.js +80 -0
  98. package/dist/esm/nodes/cache/store.js.map +1 -0
  99. package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
  100. package/dist/esm/nodes/cart/cart-get-total.js +16 -0
  101. package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
  102. package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
  103. package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
  104. package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
  105. package/dist/esm/nodes/index.d.ts.map +1 -1
  106. package/dist/esm/nodes/index.js +5 -1
  107. package/dist/esm/nodes/index.js.map +1 -1
  108. package/dist/esm/runtime-storage-generator.d.ts +18 -0
  109. package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
  110. package/dist/esm/runtime-storage-generator.js +62 -2
  111. package/dist/esm/runtime-storage-generator.js.map +1 -1
  112. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +4 -4
  114. package/src/ecommerce/stock-decrement.ts +83 -11
  115. package/src/executor-generator.ts +93 -15
  116. package/src/index.ts +1 -0
  117. package/src/invoice/api-routes-code.ts +31 -2
  118. package/src/invoice/invoice-html-code.ts +25 -4
  119. package/src/nodes/account/account-refresh-session.ts +97 -0
  120. package/src/nodes/cache/cache-get.ts +38 -0
  121. package/src/nodes/cache/cache-invalidate.ts +95 -0
  122. package/src/nodes/cache/cache-set.ts +71 -0
  123. package/src/nodes/cache/store.ts +80 -0
  124. package/src/nodes/cart/cart-get-total.ts +18 -0
  125. package/src/nodes/file-storage/file-storage-upload.ts +51 -6
  126. package/src/nodes/index.ts +8 -0
  127. package/src/runtime-storage-generator.ts +63 -2
@@ -0,0 +1,127 @@
1
+ import { generateClientRuntimeCode } from '../src/executor-generator'
2
+
3
+ // A server segment resolves its node configs from the context the CLIENT
4
+ // POSTs, and `pruneContext` is what builds that payload. It used to replace
5
+ // any context entry whose serialization exceeded 100 000 characters with a
6
+ // single `{ __truncated: true }` marker — the entire entry, not just the part
7
+ // that was too big.
8
+ //
9
+ // That is exactly the shape the profile form produces. Picking a new photo
10
+ // writes the file's base64 dataURL into `accountFormData.image`, so the
11
+ // `state-get-local-state` node that reads the form data ends up holding
12
+ // { value: { name: 'Ada Lovelace', image: 'data:image/png;base64,<~2 MB>' } }
13
+ // The whole entry blew the budget, the server saw `{ __truncated: true }`,
14
+ // `[<node>, 'value', 'name']` resolved to `undefined`, and `data-update-item`
15
+ // DROPS an undefined column mapping — so saving a new picture silently stopped
16
+ // saving the name (and every custom account property) alongside it.
17
+ //
18
+ // The pruner now descends: only the oversized LEAF becomes a marker, and every
19
+ // sibling still crosses intact.
20
+
21
+ type PruneFn = (ctx: Record<string, unknown>) => Record<string, any>
22
+
23
+ function loadPruneContext(): PruneFn {
24
+ const src = generateClientRuntimeCode()
25
+ const grab = (pattern: RegExp, label: string): string => {
26
+ const match = src.match(pattern)
27
+ if (!match) {
28
+ throw new Error(`${label} not found in generated client runtime`)
29
+ }
30
+ return match[0]
31
+ }
32
+ const parts = [
33
+ grab(/const PRUNE_MAX_SERIALIZED_LENGTH = \d+;/, 'PRUNE_MAX_SERIALIZED_LENGTH'),
34
+ grab(/const PRUNE_MAX_DEPTH = \d+;/, 'PRUNE_MAX_DEPTH'),
35
+ ...[
36
+ 'isDomNode',
37
+ 'snapshotDomNode',
38
+ 'domSerializationReplacer',
39
+ 'serializeForPrune',
40
+ 'prunedValue',
41
+ 'pruneContext',
42
+ ].map((fn) => grab(new RegExp(`function ${fn}\\b[\\s\\S]*?\\n\\}`), fn)),
43
+ ]
44
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
45
+ return new Function(`${parts.join('\n')}\nreturn pruneContext;`)() as PruneFn
46
+ }
47
+
48
+ const HUGE = 'data:image/png;base64,' + 'A'.repeat(300000)
49
+
50
+ describe('pruneContext salvages the small fields next to an oversized one', () => {
51
+ const pruneContext = loadPruneContext()
52
+
53
+ it('keeps sibling keys when one property is too large to send', () => {
54
+ const pruned = pruneContext({
55
+ formData: {
56
+ value: { name: 'Ada Lovelace', image: HUGE, role: 'user' },
57
+ key: 'accountFormData',
58
+ },
59
+ })
60
+
61
+ expect(pruned.formData.value.name).toBe('Ada Lovelace')
62
+ expect(pruned.formData.value.role).toBe('user')
63
+ expect(pruned.formData.key).toBe('accountFormData')
64
+ expect(pruned.formData.value.image).toEqual({ __truncated: true, type: 'string' })
65
+ })
66
+
67
+ it('salvages per-state entries inside the __stateValues bag', () => {
68
+ const pruned = pruneContext({
69
+ __stateValues: {
70
+ accountFormData: { name: 'Ada Lovelace', image: HUGE },
71
+ currentUser: { id: 'u-1', name: 'Ada Lovelace', image: 'https://cdn/old.png' },
72
+ accountFormButtonState: 'updating',
73
+ },
74
+ })
75
+
76
+ expect(pruned.__stateValues.currentUser).toEqual({
77
+ id: 'u-1',
78
+ name: 'Ada Lovelace',
79
+ image: 'https://cdn/old.png',
80
+ })
81
+ expect(pruned.__stateValues.accountFormButtonState).toBe('updating')
82
+ expect(pruned.__stateValues.accountFormData.name).toBe('Ada Lovelace')
83
+ })
84
+
85
+ it('keeps array element positions stable while truncating a heavy entry', () => {
86
+ const pruned = pruneContext({
87
+ picked: { value: [{ name: 'photo.png', type: 'image/png', dataURL: HUGE }], key: 'files' },
88
+ })
89
+
90
+ expect(pruned.picked.value).toHaveLength(1)
91
+ expect(pruned.picked.value[0].name).toBe('photo.png')
92
+ expect(pruned.picked.value[0].type).toBe('image/png')
93
+ expect(pruned.picked.value[0].dataURL).toEqual({ __truncated: true, type: 'string' })
94
+ })
95
+
96
+ it('still replaces a bare oversized value with a marker', () => {
97
+ const pruned = pruneContext({ blob: HUGE })
98
+ expect(pruned.blob).toEqual({ __truncated: true, type: 'string' })
99
+ })
100
+
101
+ it('leaves values that fit exactly as they were', () => {
102
+ const pruned = pruneContext({ small: { a: 1, b: [2, 3], c: null } })
103
+ expect(pruned.small).toEqual({ a: 1, b: [2, 3], c: null })
104
+ })
105
+
106
+ it('bounds what one oversized container may contribute to the payload', () => {
107
+ // Ten ~50 000-char siblings: each fits on its own, but the container as a
108
+ // whole does not. The per-container budget must stop the pruned copy from
109
+ // ballooning past what the old whole-value rule allowed.
110
+ const chunks: Record<string, string> = {}
111
+ for (let i = 0; i < 10; i++) {
112
+ chunks['chunk' + i] = 'x'.repeat(50000)
113
+ }
114
+ const pruned = pruneContext({ chunks })
115
+ expect(JSON.stringify(pruned).length).toBeLessThan(160000)
116
+ expect(pruned.chunks.chunk0).toBe('x'.repeat(50000))
117
+ expect(pruned.chunks.chunk9).toEqual({ __truncated: true, type: 'string' })
118
+ })
119
+
120
+ it('never emits a payload that cannot be JSON-serialized', () => {
121
+ const circular: Record<string, unknown> = { name: 'loop' }
122
+ circular.self = circular
123
+ const pruned = pruneContext({ circular, ok: { fine: true } })
124
+ expect(() => JSON.stringify({ context: pruned })).not.toThrow()
125
+ expect(pruned.ok).toEqual({ fine: true })
126
+ })
127
+ })
@@ -0,0 +1,195 @@
1
+ // An upload must FAIL when it cannot succeed — it must never simply not finish.
2
+ //
3
+ // The incident: the runtime-storage worker's database connections went
4
+ // half-open, so every request that touched the database waited for a reply that
5
+ // was never coming. Nothing between the shopper and that socket had a deadline
6
+ // — not the generated `/api/runtime-storage/upload` proxy, not the
7
+ // `file-storage-upload` node — so the browser request stayed PENDING
8
+ // indefinitely. The workflow never reached its error branch (there was no
9
+ // error), the submitting flag was never cleared, and the review form was simply
10
+ // dead with no message.
11
+ //
12
+ // Both halves are pinned here, including the relationship between them: the
13
+ // proxy must give up FIRST, so the shopper is told what went wrong by the side
14
+ // that knows, rather than by the client abandoning a request that was still
15
+ // alive.
16
+
17
+ import { Blob as NodeBlob } from 'buffer'
18
+ import { nodeRegistry } from '../src'
19
+ import {
20
+ UPLOAD_PROXY_TIMEOUT_MS,
21
+ generateRuntimeStorageUploadRoute,
22
+ } from '../src/runtime-storage-generator'
23
+ import { loadHandler, HandlerFn } from './_helpers/load-handler'
24
+ import { installUploadGlobals, removeUploadGlobals } from './_helpers/abort-controller-stub'
25
+
26
+ class FormDataStub {
27
+ public appended: Array<{ key: string; value: unknown; fileName?: string }> = []
28
+ public append(key: string, value: unknown, fileName?: string): void {
29
+ this.appended.push({ key, value, fileName })
30
+ }
31
+ }
32
+
33
+ const PICKED_FILE = {
34
+ name: 'photo.png',
35
+ size: 9,
36
+ type: 'image/png',
37
+ lastModified: 1,
38
+ dataURL: `data:image/png;base64,${Buffer.from('png-bytes').toString('base64')}`,
39
+ }
40
+
41
+ /** The literal timeout the generated client handler carries. */
42
+ const clientTimeoutMs = (): number => {
43
+ const handlerSource = nodeRegistry['file-storage-upload'].generateHandler()
44
+ const match = /UPLOAD_TIMEOUT_MS\s*=\s*(\d+)/.exec(handlerSource)
45
+ if (!match) {
46
+ throw new Error('the file-storage-upload handler declares no UPLOAD_TIMEOUT_MS')
47
+ }
48
+ return Number(match[1])
49
+ }
50
+
51
+ describe('the generated upload proxy route', () => {
52
+ const route = generateRuntimeStorageUploadRoute()
53
+
54
+ it('aborts the upstream fetch on a deadline instead of awaiting it forever', () => {
55
+ expect(route).toContain('new AbortController()')
56
+ expect(route).toContain('signal: controller.signal')
57
+ expect(route).toContain(`var UPLOAD_TIMEOUT_MS = ${UPLOAD_PROXY_TIMEOUT_MS}`)
58
+ expect(route).toContain('clearTimeout(timer)')
59
+ })
60
+
61
+ it('answers a stalled upstream with a 504 the workflow can report', () => {
62
+ // A pending request tells the shopper nothing. A status does.
63
+ expect(route).toContain('res.status(504)')
64
+ expect(route).toContain("error: 'UPLOAD_TIMEOUT'")
65
+ })
66
+
67
+ it('survives an upstream that answers with something other than JSON', () => {
68
+ // A gateway 502 is an HTML page; parsing it as JSON used to throw inside the
69
+ // handler and be reported as a generic proxy failure, losing the status that
70
+ // explains it.
71
+ expect(route).toContain('await storageRes.text()')
72
+ expect(route).toContain('UPLOAD_UPSTREAM_INVALID_RESPONSE')
73
+ })
74
+
75
+ it('gives up BEFORE the client does, so the 504 is what the shopper sees', () => {
76
+ expect(UPLOAD_PROXY_TIMEOUT_MS).toBeLessThan(clientTimeoutMs())
77
+ })
78
+ })
79
+
80
+ describe('file-storage-upload: an upstream that never answers', () => {
81
+ let handler: HandlerFn
82
+
83
+ beforeEach(() => {
84
+ jest.useFakeTimers()
85
+ handler = loadHandler('file-storage-upload')
86
+ installUploadGlobals({
87
+ FormData: FormDataStub,
88
+ Blob: NodeBlob,
89
+ atob: (encoded: string) => Buffer.from(encoded, 'base64').toString('binary'),
90
+ })
91
+ })
92
+
93
+ afterEach(() => {
94
+ jest.useRealTimers()
95
+ removeUploadGlobals(['fetch', 'FormData', 'Blob', 'atob'])
96
+ })
97
+
98
+ it('settles with a timeout error rather than never settling', async () => {
99
+ // The exact shape of the incident: a fetch that neither resolves nor
100
+ // rejects until something aborts it.
101
+ ;(globalThis as any).fetch = jest.fn(
102
+ (_url: string, init: { signal: AbortSignal }) =>
103
+ new Promise((_resolve, reject) => {
104
+ init.signal.addEventListener('abort', () => {
105
+ const abortError = new Error('The operation was aborted.')
106
+ abortError.name = 'AbortError'
107
+ reject(abortError)
108
+ })
109
+ })
110
+ )
111
+
112
+ const pending = handler({ file: [PICKED_FILE] }, {}) as Promise<{
113
+ files: unknown[]
114
+ error: string
115
+ statusCode?: number
116
+ }>
117
+
118
+ jest.advanceTimersByTime(clientTimeoutMs())
119
+
120
+ const result = await pending
121
+ expect(result.files).toEqual([])
122
+ expect(result.statusCode).toBe(504)
123
+ expect(result.error.toLowerCase()).toContain('timed out')
124
+ })
125
+
126
+ it('passes an abort signal to the fetch at all', async () => {
127
+ const fetchMock = jest.fn(async (_url: string, _init: { signal?: { aborted: boolean } }) => ({
128
+ ok: true,
129
+ status: 200,
130
+ text: async () => JSON.stringify({ files: [] }),
131
+ }))
132
+ ;(globalThis as any).fetch = fetchMock
133
+
134
+ await handler({ file: [PICKED_FILE] }, {})
135
+
136
+ const init = fetchMock.mock.calls[0][1]
137
+ expect(init.signal).toBeDefined()
138
+ expect(typeof init.signal!.aborted).toBe('boolean')
139
+ })
140
+
141
+ it('reports the upstream status when the body is not JSON', async () => {
142
+ ;(globalThis as any).fetch = jest.fn(async () => ({
143
+ ok: false,
144
+ status: 502,
145
+ text: async () => '<html><body>Bad Gateway</body></html>',
146
+ }))
147
+
148
+ const result = (await handler({ file: [PICKED_FILE] }, {})) as {
149
+ error: string
150
+ statusCode: number
151
+ }
152
+
153
+ expect(result.statusCode).toBe(502)
154
+ // Never "Unexpected token <".
155
+ expect(result.error).toContain('502')
156
+ })
157
+
158
+ it('reports the readable message, not the machine code', async () => {
159
+ // Whatever this returns is toasted verbatim at the shopper. "Upload would
160
+ // exceed storage limit" is an explanation; "STORAGE_LIMIT_EXCEEDED" is not.
161
+ ;(globalThis as any).fetch = jest.fn(async () => ({
162
+ ok: false,
163
+ status: 413,
164
+ text: async () =>
165
+ JSON.stringify({
166
+ error: 'STORAGE_LIMIT_EXCEEDED',
167
+ message: 'Upload would exceed storage limit',
168
+ storageLimit: 20971520,
169
+ }),
170
+ }))
171
+
172
+ const result = (await handler({ file: [PICKED_FILE] }, {})) as {
173
+ error: string
174
+ statusCode: number
175
+ storageLimit: number
176
+ }
177
+
178
+ expect(result.error).toBe('Upload would exceed storage limit')
179
+ expect(result.statusCode).toBe(413)
180
+ // …and the numbers a "you need more room" message would want survive.
181
+ expect(result.storageLimit).toBe(20971520)
182
+ })
183
+
184
+ it('clears its timer on the happy path, so the tab is not held awake', async () => {
185
+ ;(globalThis as any).fetch = jest.fn(async () => ({
186
+ ok: true,
187
+ status: 200,
188
+ text: async () => JSON.stringify({ files: [{ url: 'https://cdn/a.png' }] }),
189
+ }))
190
+
191
+ await handler({ file: [PICKED_FILE] }, {})
192
+
193
+ expect(jest.getTimerCount()).toBe(0)
194
+ })
195
+ })
@@ -31,9 +31,22 @@ function extractFn(name: string, returnExpr = name): Fn {
31
31
  if (!match) {
32
32
  throw new Error(`${name} not found in generated runtime`)
33
33
  }
34
- // pruneContext depends on isDomNode/snapshotDomNode/domSerializationReplacer;
35
- // mergeServerResults depends on isDomNode. Pull those in too when present.
36
- const deps = ['isDomNode', 'snapshotDomNode', 'domSerializationReplacer']
34
+ // pruneContext depends on isDomNode/snapshotDomNode/domSerializationReplacer
35
+ // plus the recursive pruner (serializeForPrune/prunedValue) and its two size
36
+ // constants; mergeServerResults depends on isDomNode. Pull those in too.
37
+ const constDecls = ['PRUNE_MAX_SERIALIZED_LENGTH', 'PRUNE_MAX_DEPTH']
38
+ .map((c) => {
39
+ const m = src.match(new RegExp(`const ${c} = \\d+;`))
40
+ return m ? m[0] : ''
41
+ })
42
+ .join('\n')
43
+ const deps = [
44
+ 'isDomNode',
45
+ 'snapshotDomNode',
46
+ 'domSerializationReplacer',
47
+ 'serializeForPrune',
48
+ 'prunedValue',
49
+ ]
37
50
  .filter((d) => d !== name)
38
51
  .map((d) => {
39
52
  const m = src.match(new RegExp(`function ${d}\\b[\\s\\S]*?\\n\\}`))
@@ -41,7 +54,7 @@ function extractFn(name: string, returnExpr = name): Fn {
41
54
  })
42
55
  .join('\n')
43
56
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
44
- return new Function(`${deps}\n${match[0]}\nreturn ${returnExpr};`)() as Fn
57
+ return new Function(`${constDecls}\n${deps}\n${match[0]}\nreturn ${returnExpr};`)() as Fn
45
58
  }
46
59
 
47
60
  // Minimal stand-in for a DOM element with a dataset (jsdom is not loaded here).
@@ -142,6 +155,32 @@ describe('trigger element survives the server-segment round-trip', () => {
142
155
  expect(context.fresh).toEqual({ ok: 1 })
143
156
  })
144
157
 
158
+ it('mergeServerResults never replaces the client __stateValues snapshot', () => {
159
+ // The server only ever READS __stateValues (template tokens, state-get-*
160
+ // handlers) and echoes back the PRUNED copy it was given — in which an
161
+ // oversized state (a picked photo's dataURL) has been replaced by a
162
+ // truncation marker. Merging that copy back would poison the snapshot the
163
+ // remaining client nodes resolve their object-property updates against.
164
+ const live = {
165
+ accountFormData: { name: 'Ada Lovelace', image: 'data:image/png;base64,AAAA' },
166
+ currentUser: { id: 'u-1', image: 'https://cdn/old.png' },
167
+ }
168
+ const context: Record<string, any> = { __stateValues: live }
169
+
170
+ mergeServerResults(
171
+ context,
172
+ {
173
+ __stateValues: { accountFormData: { name: 'Ada Lovelace', image: { __truncated: true } } },
174
+ 'server-node': { updatedCount: 1 },
175
+ },
176
+ undefined
177
+ )
178
+
179
+ expect(context.__stateValues).toBe(live)
180
+ expect(context.__stateValues.currentUser.image).toBe('https://cdn/old.png')
181
+ expect(context['server-node']).toEqual({ updatedCount: 1 })
182
+ })
183
+
145
184
  it('mergeServerResults still protects the trigger node without an id (custom node)', () => {
146
185
  const el = fakeButton({ cartItemId: 'cart_abc' })
147
186
  const context: Record<string, any> = {
@@ -455,6 +455,83 @@ describe('reportStockWriteAudit — warns on unknown sites', () => {
455
455
  })
456
456
  })
457
457
 
458
+ // ────────────────────────────────────────────────────────────────────
459
+ // `data-raw-query` sites. SQL belongs in a data node, never in a
460
+ // `general-custom-js` node (whose source ships to the browser), so this
461
+ // is where the decrement actually lives. The auditor originally only
462
+ // understood the custom-js shape, which made it blind to BOTH the real
463
+ // decrement and any rogue raw-SQL write against the products table.
464
+ // ────────────────────────────────────────────────────────────────────
465
+ describe('data-raw-query stock writes', () => {
466
+ const ALIASED_DECREMENT =
467
+ 'UPDATE teleport_products AS p SET quantity = p.quantity - d.qty, updated_at = NOW() ' +
468
+ 'FROM unnest($1::text[], $2::int[]) AS d(id, qty) ' +
469
+ 'WHERE p.quantity IS NOT NULL AND p.id::text = d.id'
470
+
471
+ it('classifies the aliased, parameterised decrement as order-decrement', () => {
472
+ const result = classifyStockWriteSite('Place Order', {
473
+ type: 'data-raw-query',
474
+ config: { query: ALIASED_DECREMENT },
475
+ })
476
+ expect(result.category).toBe('order-decrement')
477
+ expect(result.sqlSnippet).toContain('UPDATE teleport_products')
478
+ })
479
+
480
+ it('still classifies the un-aliased decrement as order-decrement', () => {
481
+ const result = classifyStockWriteSite('Place Order', {
482
+ type: 'data-raw-query',
483
+ config: {
484
+ query: 'UPDATE teleport_products SET quantity = quantity - 1 WHERE id = $1',
485
+ },
486
+ })
487
+ expect(result.category).toBe('order-decrement')
488
+ })
489
+
490
+ it('treats an admin bulk statement as admin, not as an unexplained write', () => {
491
+ const result = classifyStockWriteSite('Admin Panel: Mark as Inactive Selected Products', {
492
+ type: 'data-raw-query',
493
+ config: {
494
+ query: 'UPDATE teleport_products SET status = $1, updated_at = NOW() WHERE id = ANY($2)',
495
+ },
496
+ })
497
+ expect(result.category).toBe('admin')
498
+ })
499
+
500
+ it('flags a raw write from an unrecognised workflow as unknown', () => {
501
+ const result = classifyStockWriteSite('Some Other Flow', {
502
+ type: 'data-raw-query',
503
+ config: { query: 'UPDATE teleport_products SET price = 0' },
504
+ })
505
+ expect(result.category).toBe('unknown')
506
+ })
507
+
508
+ it('collects raw-query writes but never read-only SELECTs', () => {
509
+ const sites = findStockWriteSites({
510
+ workflows: {
511
+ workflows: {
512
+ w1: {
513
+ id: 'w1',
514
+ name: 'Place Order',
515
+ nodes: [
516
+ { id: 'n1', type: 'data-raw-query', config: { query: ALIASED_DECREMENT } },
517
+ {
518
+ id: 'n2',
519
+ type: 'data-raw-query',
520
+ config: { query: 'SELECT id, quantity FROM teleport_products WHERE id = $1' },
521
+ },
522
+ ],
523
+ },
524
+ },
525
+ },
526
+ // tslint:disable-next-line:no-any
527
+ } as any)
528
+
529
+ expect(sites).toHaveLength(1)
530
+ expect(sites[0].nodeId).toBe('n1')
531
+ expect(sites[0].category).toBe('order-decrement')
532
+ })
533
+ })
534
+
458
535
  // ────────────────────────────────────────────────────────────────────
459
536
  // Integration test against the REAL project UIDL fixture. This is the
460
537
  // load-bearing contract test: it locks in the current expected state
@@ -17,6 +17,7 @@ export declare const classifyStockWriteSite: (workflowName: string, node: {
17
17
  type?: string;
18
18
  config?: {
19
19
  code?: string;
20
+ query?: string;
20
21
  };
21
22
  }) => {
22
23
  category: StockWriteSite['category'];
@@ -1 +1 @@
1
- {"version":3,"file":"stock-decrement.d.ts","sourceRoot":"","sources":["../../../src/ecommerce/stock-decrement.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAKxD,eAAO,MAAM,sBAAsB,uCAAuC,CAAA;AAU1E,eAAO,MAAM,8BAA8B,SAAU,MAAM,KAAG,OAgC7D,CAAA;AAsBD,eAAO,MAAM,4BAA4B,wlDAqCrC,CAAA;AA8BJ,eAAO,MAAM,0BAA0B,oBAAqB,OAAO,KAAG,MA+HrE,CAAA;AAsBD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,UAAU,GAAG,YAAY,CAAA;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,QAAQ,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAAA;IAGjD,UAAU,EAAE,MAAM,CAAA;CACnB;AA0ED,eAAO,MAAM,sBAAsB,iBACnB,MAAM,QACd;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAClD;IAAE,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAuC5D,CAAA;AAID,eAAO,MAAM,mBAAmB,SAAU,WAAW,KAAG,cAAc,EAwErE,CAAA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,cAAc,EAAE,cAAc,EAAE,CAAA;IAChC,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAID,eAAO,MAAM,oBAAoB,SAAU,WAAW,KAAG,eAQxD,CAAA;AAMD,eAAO,MAAM,qBAAqB,SAAU,WAAW,KAAG,eAmEzD,CAAA;AAuMD,UAAU,YAAY;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,iCAAiC,SAAU,WAAW,KAAG,YA6BrE,CAAA"}
1
+ {"version":3,"file":"stock-decrement.d.ts","sourceRoot":"","sources":["../../../src/ecommerce/stock-decrement.ts"],"names":[],"mappings":"AAwCA,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AAKxD,eAAO,MAAM,sBAAsB,uCAAuC,CAAA;AAU1E,eAAO,MAAM,8BAA8B,SAAU,MAAM,KAAG,OAgC7D,CAAA;AAsBD,eAAO,MAAM,4BAA4B,wlDAqCrC,CAAA;AA8BJ,eAAO,MAAM,0BAA0B,oBAAqB,OAAO,KAAG,MA+HrE,CAAA;AAsBD,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,UAAU,GAAG,YAAY,CAAA;IACvC,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,QAAQ,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAAA;IAGjD,UAAU,EAAE,MAAM,CAAA;CACnB;AAsGD,eAAO,MAAM,sBAAsB,iBACnB,MAAM,QACd;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAClE;IAAE,QAAQ,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAgE5D,CAAA;AAID,eAAO,MAAM,mBAAmB,SAAU,WAAW,KAAG,cAAc,EA2FrE,CAAA;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,KAAK,EAAE,cAAc,EAAE,CAAA;IACvB,cAAc,EAAE,cAAc,EAAE,CAAA;IAChC,OAAO,EAAE,cAAc,EAAE,CAAA;CAC1B;AAID,eAAO,MAAM,oBAAoB,SAAU,WAAW,KAAG,eAQxD,CAAA;AAMD,eAAO,MAAM,qBAAqB,SAAU,WAAW,KAAG,eAmEzD,CAAA;AAuMD,UAAU,YAAY;IACpB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,iCAAiC,SAAU,WAAW,KAAG,YA6BrE,CAAA"}
@@ -312,6 +312,37 @@ const isAdminWorkflowName = (name) => {
312
312
  // classify correctly.
313
313
  return /\badmin\b/i.test(name);
314
314
  };
315
+ /**
316
+ * The decrement statement itself, in every shape a builder has emitted.
317
+ *
318
+ * The table may carry an alias and the column may be qualified through it —
319
+ * `UPDATE teleport_products AS p SET quantity = p.quantity - d.qty …` — which is
320
+ * what the parameterised `data-raw-query` form uses. An un-aliased
321
+ * `SET quantity = quantity - …` is the older shape and still matches.
322
+ */
323
+ const ORDER_DECREMENT_SQL_RE = /UPDATE\s+teleport_products\s+(?:AS\s+)?\w*\s*SET\s+quantity\s*=\s*(?:\w+\.)?quantity\s*-/i;
324
+ /** The GREATEST / NULL-preserving guards, independent of how the row is addressed. */
325
+ const ORDER_DECREMENT_GUARD_RES = [
326
+ /quantity\s*=\s*GREATEST\s*\(\s*\d+\s*,\s*COALESCE\s*\(\s*(?:\w+\.)?quantity/i,
327
+ /quantity\s*=\s*CASE\s+WHEN\s+(?:\w+\.)?quantity\s+IS\s+NULL\s+THEN\s+NULL\s+ELSE\s+GREATEST/i,
328
+ ];
329
+ /**
330
+ * Whether a bare SQL string is the order stock decrement.
331
+ *
332
+ * Used for `data-raw-query` nodes, which is where the decrement lives now: SQL
333
+ * belongs in a data node, never in a `general-custom-js` node whose source ships
334
+ * to the browser. Without this branch the auditor sees no decrement at all and
335
+ * would equally miss a rogue raw-SQL write to the products table.
336
+ */
337
+ const looksLikeOrderDecrementSql = (sql) => {
338
+ if (typeof sql !== 'string' || sql.indexOf('teleport_products') < 0) {
339
+ return false;
340
+ }
341
+ if (ORDER_DECREMENT_SQL_RE.test(sql)) {
342
+ return true;
343
+ }
344
+ return ORDER_DECREMENT_GUARD_RES.some((re) => re.test(sql));
345
+ };
315
346
  const looksLikeOrderDecrementCustomHandler = (code) => {
316
347
  if (typeof code !== 'string') {
317
348
  return false;
@@ -338,19 +369,16 @@ const looksLikeOrderDecrementCustomHandler = (code) => {
338
369
  // NULL-preserving shape (`quantity = CASE WHEN quantity IS NULL
339
370
  // THEN NULL ELSE GREATEST(0, quantity - CASE id …) END`).
340
371
  if (code.indexOf('teleport_products') >= 0) {
341
- if (/quantity\s*=\s*quantity\s*-/.test(code)) {
342
- return true;
343
- }
344
- if (/quantity\s*=\s*GREATEST\s*\(\s*\d+\s*,\s*COALESCE\s*\(\s*quantity/i.test(code)) {
372
+ if (/quantity\s*=\s*(?:\w+\.)?quantity\s*-/.test(code)) {
345
373
  return true;
346
374
  }
347
- if (/quantity\s*=\s*CASE\s+WHEN\s+quantity\s+IS\s+NULL\s+THEN\s+NULL\s+ELSE\s+GREATEST/i.test(code)) {
375
+ if (ORDER_DECREMENT_GUARD_RES.some((re) => re.test(code))) {
348
376
  return true;
349
377
  }
350
378
  }
351
379
  return false;
352
380
  }
353
- return /UPDATE\s+teleport_products\s+SET\s+quantity\s*=\s*quantity\s*-/i.test(code);
381
+ return ORDER_DECREMENT_SQL_RE.test(code);
354
382
  };
355
383
  // Pull the first 300 chars of any SQL string in a custom-js handler.
356
384
  // Just for log readability when the auditor warns.
@@ -364,6 +392,26 @@ const extractSqlSnippet = (code) => {
364
392
  const classifyStockWriteSite = (workflowName, node) => {
365
393
  const nodeType = node.type || '';
366
394
  const code = (node.config && node.config.code) || '';
395
+ const query = (node.config && node.config.query) || '';
396
+ // `data-raw-query` sites: the decrement's current home. The SQL is a plain
397
+ // string on the node, so it is matched directly rather than through the
398
+ // custom-handler marker logic (a data node carries no rewritten handler).
399
+ if (nodeType === 'data-raw-query') {
400
+ if (looksLikeOrderDecrementSql(query)) {
401
+ return { category: 'order-decrement', sqlSnippet: extractSqlSnippet(query) };
402
+ }
403
+ if (/UPDATE\s+teleport_products|DELETE\s+(?:FROM\s+)?teleport_products|INSERT\s+(?:INTO\s+)?teleport_products/i.test(query)) {
404
+ // The admin panel's BULK actions are raw-SQL by necessity (one statement
405
+ // for N rows), so the same workflow-name signal the structured-CRUD
406
+ // branch uses applies here — otherwise every bulk delete / status change
407
+ // would be reported as an unexplained write.
408
+ if (isAdminWorkflowName(workflowName)) {
409
+ return { category: 'admin', sqlSnippet: extractSqlSnippet(query) };
410
+ }
411
+ return { category: 'unknown', sqlSnippet: extractSqlSnippet(query) };
412
+ }
413
+ return { category: 'unknown', sqlSnippet: '' };
414
+ }
367
415
  // Raw-SQL custom-js sites: distinguish "decrement" from "other write".
368
416
  if (nodeType === 'general-custom-js') {
369
417
  if (looksLikeOrderDecrementCustomHandler(code)) {
@@ -429,6 +477,24 @@ const findStockWriteSites = (uidl) => {
429
477
  });
430
478
  continue;
431
479
  }
480
+ // Raw-SQL data-node branch. Same "writes only" rule as custom-js below:
481
+ // a SELECT against teleport_products is not a write site.
482
+ if (node.type === 'data-raw-query' && typeof config.query === 'string') {
483
+ if (/\b(UPDATE|DELETE\s+FROM|INSERT\s+INTO)\s+teleport_products\b/i.test(config.query)) {
484
+ const { category, sqlSnippet } = (0, exports.classifyStockWriteSite)(workflowName, node);
485
+ sites.push({
486
+ workflowId,
487
+ workflowName,
488
+ workflowKind: kind,
489
+ nodeId: node.id || '',
490
+ nodeType: node.type,
491
+ stepNumber: node.stepNumber,
492
+ category,
493
+ sqlSnippet,
494
+ });
495
+ }
496
+ continue;
497
+ }
432
498
  // Raw-SQL custom-js branch: only count it when the code actually
433
499
  // writes (UPDATE / DELETE / INSERT) — read-only SELECTs on
434
500
  // teleport_products are not "write sites".