spine-framework 0.4.2 → 0.4.4
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/.framework/functions/_shared/agent-runner.ts +1000 -81
- package/.framework/functions/_shared/cil.ts +2 -1
- package/.framework/functions/_shared/db.ts +3 -6
- package/.framework/functions/_shared/kb.ts +61 -7
- package/.framework/functions/_shared/message-sequence.ts +53 -0
- package/.framework/functions/_shared/pipeline-runner.ts +150 -37
- package/.framework/functions/_shared/principal.ts +9 -6
- package/.framework/functions/_shared/trigger-engine.ts +149 -40
- package/.framework/functions/admin-data.ts +9 -3
- package/.framework/functions/ai-agents.ts +93 -27
- package/.framework/functions/pipeline-runner-background.ts +120 -0
- package/.framework/functions/system-cron.ts +10 -1
- package/.framework/functions/trigger-scheduler.ts +65 -5
- package/.framework/migrations/064_episode_leases.sql +196 -0
- package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
- package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
- package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
- package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
- package/.framework/schemas/spine.config.schema.json +62 -0
- package/.framework/src/App.tsx +3 -1
- package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
- package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
- package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
- package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
- package/.framework/src/components/layout/Layout.tsx +4 -2
- package/.framework/src/components/layout/Sidebar.tsx +4 -2
- package/.framework/src/hooks/useBranding.ts +47 -0
- package/.framework/src/lib/branding.ts +128 -0
- package/.framework/src/pages/NoAppPage.tsx +3 -1
- package/.framework/src/pages/SetupPage.tsx +5 -2
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
- package/.framework/src/pages/auth/LoginPage.tsx +4 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
- package/.framework/templates/project.json +1 -0
- package/.framework/tests/unit/agent-runner.test.ts +17 -3
- package/.framework/tests/unit/episode-lease.test.ts +695 -0
- package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
- package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
- package/.framework/tests/unit/principal-cache.test.ts +15 -0
- package/.framework/tests/unit/trigger-engine.test.ts +278 -91
- package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
- package/netlify.toml +5 -0
- package/package.json +1 -1
- package/templates/project/package-lock.json +2 -2
|
@@ -21,20 +21,17 @@ vi.mock('../../functions/_shared/audit.ts', () => ({
|
|
|
21
21
|
emitAudit: vi.fn().mockResolvedValue(undefined)
|
|
22
22
|
}))
|
|
23
23
|
|
|
24
|
-
vi.mock('../../functions/_shared/pipeline-runner.ts', () => ({
|
|
25
|
-
runPipeline: vi.fn().mockResolvedValue({
|
|
26
|
-
executionId: 'mock-exec-id',
|
|
27
|
-
pipelineId: 'mock-pipeline-id',
|
|
28
|
-
status: 'completed',
|
|
29
|
-
stages: [],
|
|
30
|
-
durationMs: 10
|
|
31
|
-
})
|
|
32
|
-
}))
|
|
33
|
-
|
|
34
24
|
import { checkAndFireTriggers, fireCreateTriggers, fireUpdateTriggers } from '../../functions/_shared/trigger-engine.ts'
|
|
35
|
-
import { runPipeline } from '../../functions/_shared/pipeline-runner.ts'
|
|
36
25
|
import { emitAudit } from '../../functions/_shared/audit.ts'
|
|
37
26
|
import { adminDb } from '../../functions/_shared/db.ts'
|
|
27
|
+
|
|
28
|
+
// dispatchTriggerPipeline POSTs to /.netlify/functions/pipeline-runner-background.
|
|
29
|
+
// Stub fetch globally and the env vars it reads; the dispatch returns
|
|
30
|
+
// immediately (fire-and-forget) so a resolved ok:true is the happy path.
|
|
31
|
+
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, text: async () => '' })
|
|
32
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
33
|
+
vi.stubEnv('INTERNAL_INVOKE_SECRET', 'test-secret')
|
|
34
|
+
vi.stubEnv('SITE_URL', 'http://test.local')
|
|
38
35
|
// CIL[DOC-VECTOR type="context" content="purpose='Test setup and imports for trigger engine tests'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/trigger-engine.ts,.framework/functions/_shared/pipeline-runner.ts"]
|
|
39
36
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
40
37
|
// CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
|
|
@@ -129,13 +126,18 @@ function mockDbTriggers(triggers: any[]) {
|
|
|
129
126
|
// CIL[DOC-MICRO logic_summary="Sets up mocks for trigger query and stat update operations" notable_decisions="[]"]
|
|
130
127
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
131
128
|
// CIL[DOC-STABILITY stability="stable"]
|
|
132
|
-
function mockDbTriggersWithUpdate(triggers: any[]) {
|
|
129
|
+
function mockDbTriggersWithUpdate(triggers: any[], execId = 'exec-mock-id') {
|
|
133
130
|
const updateChain = {
|
|
134
131
|
update: vi.fn().mockReturnThis(),
|
|
135
132
|
eq: vi.fn().mockResolvedValue({ data: null, error: null })
|
|
136
133
|
}
|
|
134
|
+
const insertChain = {
|
|
135
|
+
insert: vi.fn().mockReturnThis(),
|
|
136
|
+
select: vi.fn().mockReturnThis(),
|
|
137
|
+
single: vi.fn().mockResolvedValue({ data: { id: execId }, error: null })
|
|
138
|
+
}
|
|
137
139
|
let callCount = 0
|
|
138
|
-
vi.mocked(adminDb.from).mockImplementation(() => {
|
|
140
|
+
vi.mocked(adminDb.from).mockImplementation((table: string) => {
|
|
139
141
|
callCount++
|
|
140
142
|
if (callCount === 1) {
|
|
141
143
|
return {
|
|
@@ -144,6 +146,7 @@ function mockDbTriggersWithUpdate(triggers: any[]) {
|
|
|
144
146
|
order: vi.fn().mockResolvedValue({ data: triggers, error: null })
|
|
145
147
|
} as any
|
|
146
148
|
}
|
|
149
|
+
if (table === 'pipeline_executions') return insertChain as any
|
|
147
150
|
return updateChain as any
|
|
148
151
|
})
|
|
149
152
|
vi.mocked(adminDb.rpc).mockResolvedValue({ data: 1, error: null } as any)
|
|
@@ -172,7 +175,7 @@ describe('checkAndFireTriggers', () => {
|
|
|
172
175
|
it('does nothing when no triggers match the event type', async () => {
|
|
173
176
|
mockDbTriggers([])
|
|
174
177
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
175
|
-
expect(
|
|
178
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
176
179
|
})
|
|
177
180
|
|
|
178
181
|
it('does nothing when DB query errors', async () => {
|
|
@@ -183,82 +186,163 @@ describe('checkAndFireTriggers', () => {
|
|
|
183
186
|
}
|
|
184
187
|
vi.mocked(adminDb.from).mockReturnValue(chain as any)
|
|
185
188
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
186
|
-
expect(
|
|
189
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
187
190
|
})
|
|
188
191
|
|
|
189
|
-
it('
|
|
192
|
+
it('dispatches pipeline for a matching trigger with no conditions', async () => {
|
|
190
193
|
mockDbTriggersWithUpdate([makeTrigger()])
|
|
191
194
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
192
|
-
expect(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
196
|
+
const [url, init] = mockFetch.mock.calls[0]
|
|
197
|
+
expect(url).toBe('http://test.local/.netlify/functions/pipeline-runner-background')
|
|
198
|
+
const body = JSON.parse(init.body as string)
|
|
199
|
+
expect(body.pipeline_id).toBe('pipeline-1')
|
|
200
|
+
expect(body.trigger_data.event).toBe('item_created')
|
|
201
|
+
expect(body.trigger_data.entity.id).toBe('item-1')
|
|
202
|
+
expect(init.headers['X-Internal-Secret']).toBe('test-secret')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('pre-creates a queued pipeline_executions row before dispatch', async () => {
|
|
206
|
+
const insertChain = {
|
|
207
|
+
insert: vi.fn().mockReturnThis(),
|
|
208
|
+
select: vi.fn().mockReturnThis(),
|
|
209
|
+
single: vi.fn().mockResolvedValue({ data: { id: 'exec-queued-1' }, error: null })
|
|
210
|
+
}
|
|
211
|
+
vi.mocked(adminDb.from).mockImplementation((table: string) => {
|
|
212
|
+
if (table === 'triggers') {
|
|
213
|
+
return {
|
|
214
|
+
select: vi.fn().mockReturnThis(),
|
|
215
|
+
eq: vi.fn().mockReturnThis(),
|
|
216
|
+
order: vi.fn().mockResolvedValue({ data: [makeTrigger()], error: null })
|
|
217
|
+
} as any
|
|
218
|
+
}
|
|
219
|
+
if (table === 'pipeline_executions') return insertChain as any
|
|
220
|
+
return { update: vi.fn().mockReturnThis(), eq: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
|
|
221
|
+
})
|
|
222
|
+
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
223
|
+
expect(insertChain.insert).toHaveBeenCalledWith(
|
|
224
|
+
expect.objectContaining({ status: 'queued', pipeline_id: 'pipeline-1' })
|
|
200
225
|
)
|
|
226
|
+
// The execution_id from the queued row is forwarded in the dispatch body.
|
|
227
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
228
|
+
expect(body.execution_id).toBe('exec-queued-1')
|
|
201
229
|
})
|
|
202
230
|
|
|
203
231
|
it('passes trigger metadata in the trigger data', async () => {
|
|
204
232
|
mockDbTriggersWithUpdate([makeTrigger({ id: 'trig-xyz', name: 'My Trigger' })])
|
|
205
233
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
trigger: { id: 'trig-xyz', name: 'My Trigger' }
|
|
210
|
-
}),
|
|
211
|
-
expect.any(Object)
|
|
212
|
-
)
|
|
234
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
235
|
+
expect(body.trigger_data.trigger).toEqual({ id: 'trig-xyz', name: 'My Trigger' })
|
|
236
|
+
expect(body.trigger_id).toBe('trig-xyz')
|
|
213
237
|
})
|
|
214
238
|
|
|
215
|
-
it('emits audit on successful trigger fire', async () => {
|
|
239
|
+
it('emits audit on successful trigger fire with queued status', async () => {
|
|
216
240
|
mockDbTriggersWithUpdate([makeTrigger()])
|
|
217
241
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
218
242
|
expect(emitAudit).toHaveBeenCalledWith(
|
|
219
243
|
expect.any(Object),
|
|
220
244
|
'trigger.fired',
|
|
221
245
|
expect.objectContaining({ type: 'trigger', id: 'trigger-1' }),
|
|
222
|
-
expect.objectContaining({ execution_status: '
|
|
246
|
+
expect.objectContaining({ execution_status: 'queued' })
|
|
223
247
|
)
|
|
224
248
|
})
|
|
225
249
|
|
|
226
|
-
it('emits audit.failed and continues when
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
it('emits audit.failed and continues when the execution insert throws', async () => {
|
|
251
|
+
vi.mocked(adminDb.from).mockImplementation((table: string) => {
|
|
252
|
+
if (table === 'triggers') {
|
|
253
|
+
return {
|
|
254
|
+
select: vi.fn().mockReturnThis(),
|
|
255
|
+
eq: vi.fn().mockReturnThis(),
|
|
256
|
+
order: vi.fn().mockResolvedValue({ data: [makeTrigger({ id: 'bad-trigger' })], error: null })
|
|
257
|
+
} as any
|
|
258
|
+
}
|
|
259
|
+
if (table === 'pipeline_executions') {
|
|
260
|
+
throw new Error('pipeline_executions insert exploded')
|
|
261
|
+
}
|
|
262
|
+
return { update: vi.fn().mockReturnThis(), eq: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
|
|
263
|
+
})
|
|
264
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({ data: 1, error: null } as any)
|
|
229
265
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
230
266
|
expect(emitAudit).toHaveBeenCalledWith(
|
|
231
267
|
expect.any(Object),
|
|
232
268
|
'trigger.failed',
|
|
233
269
|
expect.objectContaining({ id: 'bad-trigger' }),
|
|
234
|
-
expect.objectContaining({ error: '
|
|
270
|
+
expect.objectContaining({ error: 'pipeline_executions insert exploded' })
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('still emits trigger.fired with queued status when dispatch fetch fails', async () => {
|
|
275
|
+
mockDbTriggersWithUpdate([makeTrigger()])
|
|
276
|
+
mockFetch.mockRejectedValueOnce(new Error('network unreachable'))
|
|
277
|
+
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
278
|
+
// dispatchTriggerPipeline catches the fetch error internally — the
|
|
279
|
+
// trigger.fired audit still fires with execution_status='queued'.
|
|
280
|
+
expect(emitAudit).toHaveBeenCalledWith(
|
|
281
|
+
expect.any(Object),
|
|
282
|
+
'trigger.fired',
|
|
283
|
+
expect.objectContaining({ id: 'trigger-1' }),
|
|
284
|
+
expect.objectContaining({ execution_status: 'queued' })
|
|
235
285
|
)
|
|
236
286
|
})
|
|
237
287
|
|
|
238
|
-
it('
|
|
288
|
+
it('dispatches all matching triggers, not just the first', async () => {
|
|
239
289
|
const t1 = makeTrigger({ id: 'trig-1', pipeline_id: 'pipe-1' })
|
|
240
290
|
const t2 = makeTrigger({ id: 'trig-2', pipeline_id: 'pipe-2' })
|
|
241
291
|
mockDbTriggersWithUpdate([t1, t2])
|
|
242
|
-
vi.mocked(runPipeline)
|
|
243
|
-
.mockResolvedValueOnce({ executionId: 'exec-1', pipelineId: 'pipe-1', status: 'completed', stages: [], durationMs: 5 })
|
|
244
|
-
.mockResolvedValueOnce({ executionId: 'exec-2', pipelineId: 'pipe-2', status: 'completed', stages: [], durationMs: 5 })
|
|
245
|
-
|
|
246
292
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
247
|
-
expect(
|
|
248
|
-
|
|
249
|
-
expect(
|
|
293
|
+
expect(mockFetch).toHaveBeenCalledTimes(2)
|
|
294
|
+
const bodies = mockFetch.mock.calls.map(([, init]) => JSON.parse(init.body as string))
|
|
295
|
+
expect(bodies[0].pipeline_id).toBe('pipe-1')
|
|
296
|
+
expect(bodies[1].pipeline_id).toBe('pipe-2')
|
|
250
297
|
})
|
|
251
298
|
|
|
252
|
-
it('continues to
|
|
299
|
+
it('continues to dispatch remaining triggers after a DB error on one', async () => {
|
|
253
300
|
const t1 = makeTrigger({ id: 'trig-fail', pipeline_id: 'pipe-1' })
|
|
254
301
|
const t2 = makeTrigger({ id: 'trig-ok', pipeline_id: 'pipe-2' })
|
|
255
|
-
|
|
256
|
-
vi.mocked(
|
|
257
|
-
|
|
258
|
-
|
|
302
|
+
let insertCount = 0
|
|
303
|
+
vi.mocked(adminDb.from).mockImplementation((table: string) => {
|
|
304
|
+
if (table === 'triggers') {
|
|
305
|
+
return {
|
|
306
|
+
select: vi.fn().mockReturnThis(),
|
|
307
|
+
eq: vi.fn().mockReturnThis(),
|
|
308
|
+
order: vi.fn().mockResolvedValue({ data: [t1, t2], error: null })
|
|
309
|
+
} as any
|
|
310
|
+
}
|
|
311
|
+
if (table === 'pipeline_executions') {
|
|
312
|
+
insertCount++
|
|
313
|
+
if (insertCount === 1) throw new Error('insert failed for trig-fail')
|
|
314
|
+
return {
|
|
315
|
+
insert: vi.fn().mockReturnThis(),
|
|
316
|
+
select: vi.fn().mockReturnThis(),
|
|
317
|
+
single: vi.fn().mockResolvedValue({ data: { id: 'exec-2' }, error: null })
|
|
318
|
+
} as any
|
|
319
|
+
}
|
|
320
|
+
return { update: vi.fn().mockReturnThis(), eq: vi.fn().mockResolvedValue({ data: null, error: null }) } as any
|
|
321
|
+
})
|
|
322
|
+
vi.mocked(adminDb.rpc).mockResolvedValue({ data: 1, error: null } as any)
|
|
323
|
+
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
324
|
+
// trig-fail errored during insert; trig-ok still dispatched.
|
|
325
|
+
expect(mockFetch).toHaveBeenCalledTimes(1)
|
|
326
|
+
expect(emitAudit).toHaveBeenCalledWith(
|
|
327
|
+
expect.any(Object),
|
|
328
|
+
'trigger.failed',
|
|
329
|
+
expect.objectContaining({ id: 'trig-fail' }),
|
|
330
|
+
expect.objectContaining({ error: 'insert failed for trig-fail' })
|
|
331
|
+
)
|
|
332
|
+
expect(emitAudit).toHaveBeenCalledWith(
|
|
333
|
+
expect.any(Object),
|
|
334
|
+
'trigger.fired',
|
|
335
|
+
expect.objectContaining({ id: 'trig-ok' }),
|
|
336
|
+
expect.objectContaining({ execution_status: 'queued' })
|
|
337
|
+
)
|
|
338
|
+
})
|
|
259
339
|
|
|
340
|
+
it('bounds the dispatch fetch with a 5s abort signal', async () => {
|
|
341
|
+
mockDbTriggersWithUpdate([makeTrigger()])
|
|
260
342
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
261
|
-
|
|
343
|
+
const init = mockFetch.mock.calls[0][1]
|
|
344
|
+
expect(init.signal).toBeInstanceOf(AbortSignal)
|
|
345
|
+
expect(init.signal.aborted).toBe(false)
|
|
262
346
|
})
|
|
263
347
|
})
|
|
264
348
|
// CIL[DOC-VECTOR type="context" content="purpose='Test checkAndFireTriggers core functionality'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/trigger-engine.ts,.framework/functions/_shared/pipeline-runner.ts"]
|
|
@@ -286,70 +370,70 @@ describe('trigger condition filtering', () => {
|
|
|
286
370
|
const trigger = makeTrigger({ config: { entity_type: 'people' } })
|
|
287
371
|
mockDbTriggers([trigger])
|
|
288
372
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
289
|
-
expect(
|
|
373
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
290
374
|
})
|
|
291
375
|
|
|
292
376
|
it('fires trigger when entity_type filter matches', async () => {
|
|
293
377
|
const trigger = makeTrigger({ config: { entity_type: 'items' } })
|
|
294
378
|
mockDbTriggersWithUpdate([trigger])
|
|
295
379
|
await checkAndFireTriggers('item_created', 'items', 'item-1', {}, makeCtx())
|
|
296
|
-
expect(
|
|
380
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
297
381
|
})
|
|
298
382
|
|
|
299
383
|
it('skips trigger when type_slug filter does not match', async () => {
|
|
300
384
|
const trigger = makeTrigger({ config: { type_slug: 'invoice' } })
|
|
301
385
|
mockDbTriggers([trigger])
|
|
302
386
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { type_slug: 'support_ticket' }, makeCtx())
|
|
303
|
-
expect(
|
|
387
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
304
388
|
})
|
|
305
389
|
|
|
306
390
|
it('fires trigger when type_slug filter matches', async () => {
|
|
307
391
|
const trigger = makeTrigger({ config: { type_slug: 'support_ticket' } })
|
|
308
392
|
mockDbTriggersWithUpdate([trigger])
|
|
309
393
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { type_slug: 'support_ticket' }, makeCtx())
|
|
310
|
-
expect(
|
|
394
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
311
395
|
})
|
|
312
396
|
|
|
313
397
|
it('skips trigger when simple filter value does not match', async () => {
|
|
314
398
|
const trigger = makeTrigger({ config: { filters: { status: 'open' } } })
|
|
315
399
|
mockDbTriggers([trigger])
|
|
316
400
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { status: 'closed' }, makeCtx())
|
|
317
|
-
expect(
|
|
401
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
318
402
|
})
|
|
319
403
|
|
|
320
404
|
it('fires trigger when simple filter value matches', async () => {
|
|
321
405
|
const trigger = makeTrigger({ config: { filters: { status: 'open' } } })
|
|
322
406
|
mockDbTriggersWithUpdate([trigger])
|
|
323
407
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { status: 'open' }, makeCtx())
|
|
324
|
-
expect(
|
|
408
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
325
409
|
})
|
|
326
410
|
|
|
327
411
|
it('fires trigger when array filter includes the value', async () => {
|
|
328
412
|
const trigger = makeTrigger({ config: { filters: { priority: ['high', 'critical'] } } })
|
|
329
413
|
mockDbTriggersWithUpdate([trigger])
|
|
330
414
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { priority: 'high' }, makeCtx())
|
|
331
|
-
expect(
|
|
415
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
332
416
|
})
|
|
333
417
|
|
|
334
418
|
it('skips trigger when value not in array filter', async () => {
|
|
335
419
|
const trigger = makeTrigger({ config: { filters: { priority: ['high', 'critical'] } } })
|
|
336
420
|
mockDbTriggers([trigger])
|
|
337
421
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { priority: 'low' }, makeCtx())
|
|
338
|
-
expect(
|
|
422
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
339
423
|
})
|
|
340
424
|
|
|
341
425
|
it('resolves nested dot-notation filter paths', async () => {
|
|
342
426
|
const trigger = makeTrigger({ config: { filters: { 'data.region': 'us-west' } } })
|
|
343
427
|
mockDbTriggersWithUpdate([trigger])
|
|
344
428
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { data: { region: 'us-west' } }, makeCtx())
|
|
345
|
-
expect(
|
|
429
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
346
430
|
})
|
|
347
431
|
|
|
348
432
|
it('resolves Postgres-style ->> filter paths against the data column', async () => {
|
|
349
433
|
const trigger = makeTrigger({ config: { filters: { "data->>'sender_type'": { $eq: 'agent' } } } })
|
|
350
434
|
mockDbTriggersWithUpdate([trigger])
|
|
351
435
|
await checkAndFireTriggers('message_created', 'messages', 'msg-1', { data: { sender_type: 'agent' } }, makeCtx())
|
|
352
|
-
expect(
|
|
436
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
353
437
|
})
|
|
354
438
|
|
|
355
439
|
it('maps plural table names to singular event types (fireCreateTriggers)', async () => {
|
|
@@ -357,51 +441,55 @@ describe('trigger condition filtering', () => {
|
|
|
357
441
|
mockDbTriggersWithUpdate([trigger])
|
|
358
442
|
// admin-data passes the table name 'items'; the seeded event is 'item_created'
|
|
359
443
|
await fireCreateTriggers('items', 'item-1', {}, makeCtx())
|
|
360
|
-
expect(
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
expect.any(Object)
|
|
365
|
-
)
|
|
444
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
445
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
446
|
+
expect(body.pipeline_id).toBe('pipeline-1')
|
|
447
|
+
expect(body.trigger_data.event).toBe('item_created')
|
|
366
448
|
})
|
|
367
449
|
|
|
368
450
|
it('maps plural table names to singular event types (fireUpdateTriggers)', async () => {
|
|
369
451
|
const trigger = makeTrigger({ config: { entity_type: 'items' } })
|
|
370
452
|
mockDbTriggersWithUpdate([trigger])
|
|
371
453
|
await fireUpdateTriggers('items', 'item-1', {}, makeCtx())
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
expect.any(Object)
|
|
376
|
-
)
|
|
454
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
455
|
+
expect(body.pipeline_id).toBe('pipeline-1')
|
|
456
|
+
expect(body.trigger_data.event).toBe('item_updated')
|
|
377
457
|
})
|
|
378
458
|
|
|
379
459
|
it('maps people → person for event types', async () => {
|
|
380
460
|
const trigger = makeTrigger({ config: { entity_type: 'people' } })
|
|
381
461
|
mockDbTriggersWithUpdate([trigger])
|
|
382
462
|
await fireCreateTriggers('people', 'person-1', {}, makeCtx())
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
expect.any(Object)
|
|
387
|
-
)
|
|
463
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
|
|
464
|
+
expect(body.pipeline_id).toBe('pipeline-1')
|
|
465
|
+
expect(body.trigger_data.event).toBe('person_created')
|
|
388
466
|
})
|
|
389
467
|
|
|
390
468
|
it('resolves type_slug from entityData.type_id when the slug is not inline', async () => {
|
|
391
469
|
const trigger = makeTrigger({ config: { entity_type: 'items', type_slug: 'kb_article' } })
|
|
392
|
-
const
|
|
470
|
+
const insertChain = {
|
|
471
|
+
insert: vi.fn().mockReturnThis(),
|
|
393
472
|
select: vi.fn().mockReturnThis(),
|
|
394
|
-
|
|
395
|
-
order: vi.fn().mockResolvedValue({ data: [trigger], error: null }),
|
|
396
|
-
maybeSingle: vi.fn().mockResolvedValue({ data: { slug: 'kb_article' }, error: null }),
|
|
397
|
-
update: vi.fn().mockReturnThis()
|
|
473
|
+
single: vi.fn().mockResolvedValue({ data: { id: 'exec-1' }, error: null })
|
|
398
474
|
}
|
|
399
475
|
vi.mocked(adminDb.from).mockImplementation((table: string) => {
|
|
400
|
-
if (table === 'types')
|
|
401
|
-
|
|
476
|
+
if (table === 'types') {
|
|
477
|
+
return {
|
|
478
|
+
select: vi.fn().mockReturnThis(),
|
|
479
|
+
eq: vi.fn().mockReturnThis(),
|
|
480
|
+
maybeSingle: vi.fn().mockResolvedValue({ data: { slug: 'kb_article' }, error: null })
|
|
481
|
+
} as any
|
|
482
|
+
}
|
|
483
|
+
if (table === 'pipeline_executions') return insertChain as any
|
|
484
|
+
return {
|
|
485
|
+
select: vi.fn().mockReturnThis(),
|
|
486
|
+
eq: vi.fn().mockReturnThis(),
|
|
487
|
+
order: vi.fn().mockResolvedValue({ data: [trigger], error: null }),
|
|
488
|
+
update: vi.fn().mockReturnThis()
|
|
489
|
+
} as any
|
|
402
490
|
})
|
|
403
491
|
await checkAndFireTriggers('item_created', 'items', 'item-1', { type_id: 'type-uuid-1', status: 'published' }, makeCtx())
|
|
404
|
-
expect(
|
|
492
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
405
493
|
})
|
|
406
494
|
|
|
407
495
|
// CIL[DOC-NOTE type="behavior" content="The demo seed filter { data->>'ca_kb_candidate': { $eq: true } } relies on strict boolean equality — the jsonb value is a real boolean, so a string 'true' operand would never fire."]
|
|
@@ -414,7 +502,7 @@ describe('trigger condition filtering', () => {
|
|
|
414
502
|
})
|
|
415
503
|
mockDbTriggersWithUpdate([trigger])
|
|
416
504
|
await checkAndFireTriggers('item_updated', 'items', 'item-1', { type_slug: 'demo_support_ticket', data: { ca_kb_candidate: true } }, makeCtx())
|
|
417
|
-
expect(
|
|
505
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
418
506
|
})
|
|
419
507
|
|
|
420
508
|
it('does not fire a boolean data->> field when $eq uses the string "true"', async () => {
|
|
@@ -423,7 +511,7 @@ describe('trigger condition filtering', () => {
|
|
|
423
511
|
})
|
|
424
512
|
mockDbTriggers([trigger])
|
|
425
513
|
await checkAndFireTriggers('item_updated', 'items', 'item-1', { data: { ca_kb_candidate: true } }, makeCtx())
|
|
426
|
-
expect(
|
|
514
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
427
515
|
})
|
|
428
516
|
|
|
429
517
|
it('skips a boolean $eq filter when the data flag is false', async () => {
|
|
@@ -432,7 +520,7 @@ describe('trigger condition filtering', () => {
|
|
|
432
520
|
})
|
|
433
521
|
mockDbTriggers([trigger])
|
|
434
522
|
await checkAndFireTriggers('item_updated', 'items', 'item-1', { data: { ca_kb_candidate: false } }, makeCtx())
|
|
435
|
-
expect(
|
|
523
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
436
524
|
})
|
|
437
525
|
})
|
|
438
526
|
// CIL[DOC-VECTOR type="context" content="purpose='Test trigger condition filtering logic'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/trigger-engine.ts,.framework/functions/_shared/pipeline-runner.ts"]
|
|
@@ -465,9 +553,9 @@ describe('filter operators', () => {
|
|
|
465
553
|
}
|
|
466
554
|
await checkAndFireTriggers('item_created', 'items', 'item-1', entityData, makeCtx())
|
|
467
555
|
if (shouldFire) {
|
|
468
|
-
expect(
|
|
556
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
469
557
|
} else {
|
|
470
|
-
expect(
|
|
558
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
471
559
|
}
|
|
472
560
|
vi.clearAllMocks()
|
|
473
561
|
}
|
|
@@ -510,4 +598,103 @@ describe('filter operators', () => {
|
|
|
510
598
|
// CIL[DOC-VECTOR type="constraint" content="Standard runtime; no special invariants documented"]
|
|
511
599
|
// CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
|
|
512
600
|
// CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
|
|
513
|
-
// CIL[END stable_id="~/.framework/tests/unit/trigger-engine.test.ts:describe_filterOperators:block"]
|
|
601
|
+
// CIL[END stable_id="~/.framework/tests/unit/trigger-engine.test.ts:describe_filterOperators:block"]
|
|
602
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/trigger-engine.test.ts:describe_fireOnTransition:block" type="block" identity="describe_fireOnTransition" parent="none"]
|
|
603
|
+
// CIL[DOC-MACRO purpose="Test fire_on_transition trigger semantics"]
|
|
604
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Verify transition-only triggers fire once when a record enters the matching state and skip repeat updates"]
|
|
605
|
+
// CIL[DOC-CONTEXT module="tests/unit/trigger-engine" layer="test-unit" related_files=".framework/functions/_shared/trigger-engine.ts"]
|
|
606
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="test assertions" errors="none" side_effects="Tests trigger-engine.ts module"]
|
|
607
|
+
// CIL[DOC-MICRO logic_summary="Covers transition-in firing, already-matching skip, missing-previous fallback, non-flagged repeat firing, and fireUpdateTriggers prev passthrough" notable_decisions="[]"]
|
|
608
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
609
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
610
|
+
describe('fire_on_transition triggers', () => {
|
|
611
|
+
beforeEach(() => {
|
|
612
|
+
vi.clearAllMocks()
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
const publishTrigger = () => makeTrigger({
|
|
616
|
+
event_type: 'item_updated',
|
|
617
|
+
config: {
|
|
618
|
+
entity_type: 'items',
|
|
619
|
+
type_slug: 'demo_kb_article',
|
|
620
|
+
fire_on_transition: true,
|
|
621
|
+
filters: { status: { $eq: 'published' } }
|
|
622
|
+
}
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('fires when the record transitions into the matching state', async () => {
|
|
626
|
+
mockDbTriggersWithUpdate([publishTrigger()])
|
|
627
|
+
const entityData = { status: 'published', type_slug: 'demo_kb_article' }
|
|
628
|
+
const previous = { status: 'draft', type_slug: 'demo_kb_article' }
|
|
629
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1', entityData, makeCtx(), previous)
|
|
630
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('skips when the record already matched before the update', async () => {
|
|
634
|
+
mockDbTriggersWithUpdate([publishTrigger()])
|
|
635
|
+
const entityData = { status: 'published', type_slug: 'demo_kb_article' }
|
|
636
|
+
const previous = { status: 'published', type_slug: 'demo_kb_article' }
|
|
637
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1', entityData, makeCtx(), previous)
|
|
638
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
639
|
+
expect(adminDb.rpc).not.toHaveBeenCalled()
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
it('fires when no previous snapshot is provided (conservative)', async () => {
|
|
643
|
+
mockDbTriggersWithUpdate([publishTrigger()])
|
|
644
|
+
const entityData = { status: 'published', type_slug: 'demo_kb_article' }
|
|
645
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1', entityData, makeCtx())
|
|
646
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('still fires on repeat matches when the flag is absent', async () => {
|
|
650
|
+
const trigger = makeTrigger({
|
|
651
|
+
event_type: 'item_updated',
|
|
652
|
+
config: {
|
|
653
|
+
entity_type: 'items',
|
|
654
|
+
type_slug: 'demo_kb_article',
|
|
655
|
+
filters: { status: { $eq: 'published' } }
|
|
656
|
+
}
|
|
657
|
+
})
|
|
658
|
+
mockDbTriggersWithUpdate([trigger])
|
|
659
|
+
const entityData = { status: 'published', type_slug: 'demo_kb_article' }
|
|
660
|
+
const previous = { status: 'published', type_slug: 'demo_kb_article' }
|
|
661
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1', entityData, makeCtx(), previous)
|
|
662
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('revoke-style trigger fires only when leaving the published state', async () => {
|
|
666
|
+
const trigger = makeTrigger({
|
|
667
|
+
event_type: 'item_updated',
|
|
668
|
+
config: {
|
|
669
|
+
entity_type: 'items',
|
|
670
|
+
type_slug: 'demo_kb_article',
|
|
671
|
+
fire_on_transition: true,
|
|
672
|
+
filters: { status: { $ne: 'published' } }
|
|
673
|
+
}
|
|
674
|
+
})
|
|
675
|
+
mockDbTriggersWithUpdate([trigger])
|
|
676
|
+
|
|
677
|
+
// published → draft: previous fails the $ne filter, new matches → fires
|
|
678
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1',
|
|
679
|
+
{ status: 'draft', type_slug: 'demo_kb_article' }, makeCtx(),
|
|
680
|
+
{ status: 'published', type_slug: 'demo_kb_article' })
|
|
681
|
+
expect(mockFetch).toHaveBeenCalledOnce()
|
|
682
|
+
|
|
683
|
+
// draft → draft (already non-published): previous also matches → skip
|
|
684
|
+
vi.clearAllMocks()
|
|
685
|
+
mockDbTriggersWithUpdate([trigger])
|
|
686
|
+
await checkAndFireTriggers('item_updated', 'items', 'item-1',
|
|
687
|
+
{ status: 'draft', type_slug: 'demo_kb_article' }, makeCtx(),
|
|
688
|
+
{ status: 'draft', type_slug: 'demo_kb_article' })
|
|
689
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it('fireUpdateTriggers forwards the previous snapshot', async () => {
|
|
693
|
+
mockDbTriggersWithUpdate([publishTrigger()])
|
|
694
|
+
await fireUpdateTriggers('items', 'item-1',
|
|
695
|
+
{ status: 'published', type_slug: 'demo_kb_article' }, makeCtx(),
|
|
696
|
+
{ status: 'published', type_slug: 'demo_kb_article' })
|
|
697
|
+
expect(mockFetch).not.toHaveBeenCalled()
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
// CIL[END stable_id="~/.framework/tests/unit/trigger-engine.test.ts:describe_fireOnTransition:block"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/tests/unit/trigger-scheduler.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="none"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Tests for trigger-scheduler's Netlify Clockwork detection and context fabrication"]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Verify scheduled invocations bypass middleware auth via fabricated system ctx while HTTP requests take the normal path"]
|
|
4
|
+
// CIL[DOC-CONTEXT module="tests/unit/trigger-scheduler" layer="test-unit" related_files=".framework/functions/trigger-scheduler.ts,.framework/functions/system-cron.ts"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="none" outputs="test assertions" errors="none" side_effects="Mocks system-cron handler and db"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Mocks the cron handler export, invokes trigger-scheduler with scheduled and non-scheduled event shapes, asserts the fabricated ctx carries SYSTEM_PRINCIPAL" notable_decisions="[]"]
|
|
7
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
8
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
10
|
+
|
|
11
|
+
const { cronHandler } = vi.hoisted(() => ({
|
|
12
|
+
cronHandler: vi.fn().mockResolvedValue({ statusCode: 200 })
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
vi.mock('../../functions/system-cron.ts', () => ({
|
|
16
|
+
handler: cronHandler
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('../../functions/_shared/db.ts', () => ({
|
|
20
|
+
adminDb: {}
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
import { handler } from '../../functions/trigger-scheduler.ts'
|
|
24
|
+
|
|
25
|
+
describe('trigger-scheduler', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('fabricates a system-principal ctx for Netlify Clockwork invocations', async () => {
|
|
31
|
+
const event = {
|
|
32
|
+
path: '/.netlify/functions/trigger-scheduler',
|
|
33
|
+
httpMethod: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'x-nf-event': 'schedule',
|
|
36
|
+
'user-agent': 'Netlify Clockwork',
|
|
37
|
+
},
|
|
38
|
+
body: JSON.stringify({ next_run: '2026-10-23T21:07:00.000Z' }),
|
|
39
|
+
}
|
|
40
|
+
await handler(event, {})
|
|
41
|
+
|
|
42
|
+
expect(cronHandler).toHaveBeenCalledOnce()
|
|
43
|
+
const [ctx] = cronHandler.mock.calls[0]
|
|
44
|
+
// The fabricated ctx flows straight into the cron handler — createHandler
|
|
45
|
+
// short-circuits on requestId+principal without re-resolving auth.
|
|
46
|
+
expect(ctx.requestId).toBeTruthy()
|
|
47
|
+
expect(ctx.principal.id).toBe('system')
|
|
48
|
+
expect(ctx.principal.machineType).toBe('internal')
|
|
49
|
+
expect(ctx.source).toBe('netlify-scheduler')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('forwards non-scheduled HTTP requests to the normal middleware path', async () => {
|
|
53
|
+
const event = {
|
|
54
|
+
path: '/.netlify/functions/trigger-scheduler',
|
|
55
|
+
httpMethod: 'POST',
|
|
56
|
+
headers: { 'x-internal-secret': 'some-secret' },
|
|
57
|
+
body: '{}',
|
|
58
|
+
}
|
|
59
|
+
await handler(event, {})
|
|
60
|
+
|
|
61
|
+
expect(cronHandler).toHaveBeenCalledOnce()
|
|
62
|
+
// The raw event goes through untouched — createHandler resolves auth.
|
|
63
|
+
expect(cronHandler.mock.calls[0][0]).toBe(event)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('does not trust a spoofed x-nf-event header without the next_run payload', async () => {
|
|
67
|
+
const event = {
|
|
68
|
+
path: '/.netlify/functions/trigger-scheduler',
|
|
69
|
+
httpMethod: 'POST',
|
|
70
|
+
headers: { 'x-nf-event': 'schedule' },
|
|
71
|
+
body: '{}',
|
|
72
|
+
}
|
|
73
|
+
await handler(event, {})
|
|
74
|
+
|
|
75
|
+
expect(cronHandler.mock.calls[0][0]).toBe(event)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
// CIL[END stable_id="~/.framework/tests/unit/trigger-scheduler.test.ts:module_header:module_header"]
|