spine-framework 0.4.3 → 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.
Files changed (46) hide show
  1. package/.framework/functions/_shared/agent-runner.ts +1000 -81
  2. package/.framework/functions/_shared/cil.ts +2 -1
  3. package/.framework/functions/_shared/db.ts +3 -6
  4. package/.framework/functions/_shared/kb.ts +61 -7
  5. package/.framework/functions/_shared/message-sequence.ts +53 -0
  6. package/.framework/functions/_shared/pipeline-runner.ts +150 -37
  7. package/.framework/functions/_shared/principal.ts +9 -6
  8. package/.framework/functions/_shared/trigger-engine.ts +149 -40
  9. package/.framework/functions/admin-data.ts +9 -3
  10. package/.framework/functions/ai-agents.ts +93 -27
  11. package/.framework/functions/pipeline-runner-background.ts +120 -0
  12. package/.framework/functions/system-cron.ts +10 -1
  13. package/.framework/functions/trigger-scheduler.ts +65 -5
  14. package/.framework/migrations/064_episode_leases.sql +196 -0
  15. package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
  16. package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
  17. package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
  18. package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
  19. package/.framework/schemas/spine.config.schema.json +62 -0
  20. package/.framework/src/App.tsx +3 -1
  21. package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
  22. package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
  23. package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
  24. package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
  25. package/.framework/src/components/layout/Layout.tsx +4 -2
  26. package/.framework/src/components/layout/Sidebar.tsx +4 -2
  27. package/.framework/src/hooks/useBranding.ts +47 -0
  28. package/.framework/src/lib/branding.ts +128 -0
  29. package/.framework/src/pages/NoAppPage.tsx +3 -1
  30. package/.framework/src/pages/SetupPage.tsx +5 -2
  31. package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
  32. package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
  33. package/.framework/src/pages/auth/LoginPage.tsx +4 -2
  34. package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
  35. package/.framework/templates/project.json +1 -0
  36. package/.framework/tests/unit/agent-runner.test.ts +17 -3
  37. package/.framework/tests/unit/episode-lease.test.ts +695 -0
  38. package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
  39. package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
  40. package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
  41. package/.framework/tests/unit/principal-cache.test.ts +15 -0
  42. package/.framework/tests/unit/trigger-engine.test.ts +278 -91
  43. package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
  44. package/netlify.toml +5 -0
  45. package/package.json +1 -1
  46. 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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).not.toHaveBeenCalled()
189
+ expect(mockFetch).not.toHaveBeenCalled()
187
190
  })
188
191
 
189
- it('fires pipeline for a matching trigger with no conditions', async () => {
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(runPipeline).toHaveBeenCalledOnce()
193
- expect(runPipeline).toHaveBeenCalledWith(
194
- 'pipeline-1',
195
- expect.objectContaining({
196
- event: 'item_created',
197
- entity: expect.objectContaining({ type: 'items', id: 'item-1' })
198
- }),
199
- expect.any(Object)
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
- expect(runPipeline).toHaveBeenCalledWith(
207
- 'pipeline-1',
208
- expect.objectContaining({
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: 'completed' })
246
+ expect.objectContaining({ execution_status: 'queued' })
223
247
  )
224
248
  })
225
249
 
226
- it('emits audit.failed and continues when runPipeline throws', async () => {
227
- mockDbTriggers([makeTrigger({ id: 'bad-trigger' })])
228
- vi.mocked(runPipeline).mockRejectedValueOnce(new Error('Pipeline exploded'))
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: 'Pipeline exploded' })
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('fires all matching triggers, not just the first', async () => {
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(runPipeline).toHaveBeenCalledTimes(2)
248
- expect(runPipeline).toHaveBeenCalledWith('pipe-1', expect.any(Object), expect.any(Object))
249
- expect(runPipeline).toHaveBeenCalledWith('pipe-2', expect.any(Object), expect.any(Object))
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 fire remaining triggers after one fails', async () => {
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
- mockDbTriggers([t1, t2])
256
- vi.mocked(runPipeline)
257
- .mockRejectedValueOnce(new Error('pipe-1 failed'))
258
- .mockResolvedValueOnce({ executionId: 'exec-2', pipelineId: 'pipe-2', status: 'completed', stages: [], durationMs: 5 })
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
- expect(runPipeline).toHaveBeenCalledTimes(2)
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).toHaveBeenCalledOnce()
361
- expect(runPipeline).toHaveBeenCalledWith(
362
- 'pipeline-1',
363
- expect.objectContaining({ event: 'item_created' }),
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
- expect(runPipeline).toHaveBeenCalledWith(
373
- 'pipeline-1',
374
- expect.objectContaining({ event: 'item_updated' }),
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
- expect(runPipeline).toHaveBeenCalledWith(
384
- 'pipeline-1',
385
- expect.objectContaining({ event: 'person_created' }),
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 chain = {
470
+ const insertChain = {
471
+ insert: vi.fn().mockReturnThis(),
393
472
  select: vi.fn().mockReturnThis(),
394
- eq: vi.fn().mockReturnThis(),
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') return chain as any
401
- return chain as any
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).toHaveBeenCalledOnce()
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).not.toHaveBeenCalled()
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(runPipeline).toHaveBeenCalledOnce()
556
+ expect(mockFetch).toHaveBeenCalledOnce()
469
557
  } else {
470
- expect(runPipeline).not.toHaveBeenCalled()
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"]