@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.
- package/__tests__/_helpers/abort-controller-stub.ts +64 -0
- package/__tests__/account-refresh-session.test.ts +110 -0
- package/__tests__/cache-nodes.test.ts +137 -0
- package/__tests__/cart-get-total-delivery-flags.test.ts +86 -0
- package/__tests__/file-storage-upload-picked-files.test.ts +18 -15
- package/__tests__/invoice-delivery-fee.test.ts +155 -0
- package/__tests__/prune-context-oversized-leaf.test.ts +127 -0
- package/__tests__/runtime-storage-upload-never-hangs.test.ts +195 -0
- package/__tests__/runtime-trigger-element-roundtrip.test.ts +43 -4
- package/__tests__/stock-decrement-audit.test.ts +77 -0
- package/dist/cjs/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/cjs/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/cjs/ecommerce/stock-decrement.js +72 -6
- package/dist/cjs/ecommerce/stock-decrement.js.map +1 -1
- package/dist/cjs/executor-generator.d.ts.map +1 -1
- package/dist/cjs/executor-generator.js +93 -15
- package/dist/cjs/executor-generator.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/cjs/invoice/api-routes-code.js +31 -2
- package/dist/cjs/invoice/api-routes-code.js.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/cjs/invoice/invoice-html-code.js +25 -4
- package/dist/cjs/invoice/invoice-html-code.js.map +1 -1
- package/dist/cjs/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/cjs/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/cjs/nodes/account/account-refresh-session.js +96 -0
- package/dist/cjs/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-get.js +39 -0
- package/dist/cjs/nodes/cache/cache-get.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js +85 -0
- package/dist/cjs/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts +3 -0
- package/dist/cjs/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/cache-set.js +71 -0
- package/dist/cjs/nodes/cache/cache-set.js.map +1 -0
- package/dist/cjs/nodes/cache/store.d.ts +29 -0
- package/dist/cjs/nodes/cache/store.d.ts.map +1 -0
- package/dist/cjs/nodes/cache/store.js +83 -0
- package/dist/cjs/nodes/cache/store.js.map +1 -0
- package/dist/cjs/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/cjs/nodes/cart/cart-get-total.js +16 -0
- package/dist/cjs/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/cjs/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/cjs/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +5 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/runtime-storage-generator.d.ts +18 -0
- package/dist/cjs/runtime-storage-generator.d.ts.map +1 -1
- package/dist/cjs/runtime-storage-generator.js +63 -3
- package/dist/cjs/runtime-storage-generator.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/ecommerce/stock-decrement.d.ts +1 -0
- package/dist/esm/ecommerce/stock-decrement.d.ts.map +1 -1
- package/dist/esm/ecommerce/stock-decrement.js +72 -6
- package/dist/esm/ecommerce/stock-decrement.js.map +1 -1
- package/dist/esm/executor-generator.d.ts.map +1 -1
- package/dist/esm/executor-generator.js +93 -15
- package/dist/esm/executor-generator.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/invoice/api-routes-code.d.ts.map +1 -1
- package/dist/esm/invoice/api-routes-code.js +31 -2
- package/dist/esm/invoice/api-routes-code.js.map +1 -1
- package/dist/esm/invoice/invoice-html-code.d.ts.map +1 -1
- package/dist/esm/invoice/invoice-html-code.js +25 -4
- package/dist/esm/invoice/invoice-html-code.js.map +1 -1
- package/dist/esm/nodes/account/account-refresh-session.d.ts +3 -0
- package/dist/esm/nodes/account/account-refresh-session.d.ts.map +1 -0
- package/dist/esm/nodes/account/account-refresh-session.js +93 -0
- package/dist/esm/nodes/account/account-refresh-session.js.map +1 -0
- package/dist/esm/nodes/cache/cache-get.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-get.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-get.js +36 -0
- package/dist/esm/nodes/cache/cache-get.js.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-invalidate.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-invalidate.js +82 -0
- package/dist/esm/nodes/cache/cache-invalidate.js.map +1 -0
- package/dist/esm/nodes/cache/cache-set.d.ts +3 -0
- package/dist/esm/nodes/cache/cache-set.d.ts.map +1 -0
- package/dist/esm/nodes/cache/cache-set.js +68 -0
- package/dist/esm/nodes/cache/cache-set.js.map +1 -0
- package/dist/esm/nodes/cache/store.d.ts +29 -0
- package/dist/esm/nodes/cache/store.d.ts.map +1 -0
- package/dist/esm/nodes/cache/store.js +80 -0
- package/dist/esm/nodes/cache/store.js.map +1 -0
- package/dist/esm/nodes/cart/cart-get-total.d.ts.map +1 -1
- package/dist/esm/nodes/cart/cart-get-total.js +16 -0
- package/dist/esm/nodes/cart/cart-get-total.js.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.d.ts.map +1 -1
- package/dist/esm/nodes/file-storage/file-storage-upload.js +52 -6
- package/dist/esm/nodes/file-storage/file-storage-upload.js.map +1 -1
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +5 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/runtime-storage-generator.d.ts +18 -0
- package/dist/esm/runtime-storage-generator.d.ts.map +1 -1
- package/dist/esm/runtime-storage-generator.js +62 -2
- package/dist/esm/runtime-storage-generator.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/ecommerce/stock-decrement.ts +83 -11
- package/src/executor-generator.ts +93 -15
- package/src/index.ts +1 -0
- package/src/invoice/api-routes-code.ts +31 -2
- package/src/invoice/invoice-html-code.ts +25 -4
- package/src/nodes/account/account-refresh-session.ts +97 -0
- package/src/nodes/cache/cache-get.ts +38 -0
- package/src/nodes/cache/cache-invalidate.ts +95 -0
- package/src/nodes/cache/cache-set.ts +71 -0
- package/src/nodes/cache/store.ts +80 -0
- package/src/nodes/cart/cart-get-total.ts +18 -0
- package/src/nodes/file-storage/file-storage-upload.ts +51 -6
- package/src/nodes/index.ts +8 -0
- 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
|
-
//
|
|
36
|
-
|
|
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
|
|
@@ -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;
|
|
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 (
|
|
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
|
|
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".
|