Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions my-sonicjs-app/scripts/generate-cron-triggers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env npx tsx
/**
* Generate [triggers] crons in wrangler.toml from plugin cron declarations.
*
* Run this script whenever you add or remove plugin crons, then commit the
* updated wrangler.toml. A CI test in packages/core will fail if the committed
* triggers don't match the declared plugin crons.
*
* Usage:
* npx tsx scripts/generate-cron-triggers.ts
* npx tsx scripts/generate-cron-triggers.ts --check # CI mode: fail if outdated
*/

import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
import { parseCronTriggers, updateWranglerTriggers } from '../../packages/core/src/plugins/generate-triggers'

// ── Plugin list ───────────────────────────────────────────────────────────────
// Import your app's plugins here and list any that declare `crons[]`.
// (The reference app has no plugin crons yet — add them here as you create them.)
const allPlugins: Array<{ name?: string; crons?: Array<{ schedule: string }> }> = [
// e.g. emailReconciliationPlugin,
]

// ─────────────────────────────────────────────────────────────────────────────

const schedules = [...new Set(allPlugins.flatMap((p) => p.crons ?? []).map((c) => c.schedule))].sort()

const wranglerPath = resolve(__dirname, '..', 'wrangler.toml')
const current = readFileSync(wranglerPath, 'utf8')

const isCheck = process.argv.includes('--check')

if (isCheck) {
const committed = parseCronTriggers(current)
const matches =
JSON.stringify(committed) === JSON.stringify(schedules)
if (!matches) {
console.error(
'[cron-triggers] wrangler.toml triggers are out of sync.\n' +
` Committed: ${JSON.stringify(committed)}\n` +
` Expected: ${JSON.stringify(schedules)}\n` +
` Run: npx tsx scripts/generate-cron-triggers.ts`
)
process.exit(1)
}
console.log('[cron-triggers] wrangler.toml triggers are up to date.')
process.exit(0)
}

const updated = updateWranglerTriggers(current, schedules)
writeFileSync(wranglerPath, updated, 'utf8')
console.log(
schedules.length > 0
? `[cron-triggers] Updated wrangler.toml with ${schedules.length} cron(s): ${schedules.join(', ')}`
: '[cron-triggers] No plugin crons declared — [triggers] section cleared.'
)
45 changes: 21 additions & 24 deletions my-sonicjs-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
/**
* My SonicJS Application
*
* Entry point for your SonicJS headless CMS application
* Entry point for your SonicJS headless CMS application.
* Exports both `fetch` (HTTP) and `scheduled` (cron) so the Worker handles both
* cold-start paths. The `boot` function from `createSonicJSApp` ensures a
* cron-first cold isolate still gets the hook bus wired before dispatching.
*/

import { Hono } from 'hono'
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import { createSonicJSApp, registerCollections, createScheduledHandler, getHookSystem } from '@sonicjs-cms/core'
import type { SonicJSConfig } from '@sonicjs-cms/core'

// Import custom collections
Expand All @@ -29,28 +31,23 @@ const config: SonicJSConfig = {
autoSync: true
},
plugins: {
directory: './src/plugins',
autoLoad: false, // Set to true to auto-load custom plugins
disableAll: false, // Enable plugins
enabled: ['email', 'contact-form'] // Enable specific plugins
register: [contactFormPlugin],
disableAll: false,
}
}

// Create the core application
const coreApp = createSonicJSApp(config)

// Create main app and mount plugin routes manually
// (Plugin auto-mounting not yet implemented in core)
const app = new Hono()

// Mount plugin routes
if (contactFormPlugin.routes) {
for (const route of contactFormPlugin.routes) {
app.route(route.path, route.handler)
}
// Create the core application (includes boot() for cron cold-start wiring)
const app = createSonicJSApp(config)

// Export both HTTP fetch and the cron scheduled handler.
// The scheduled handler calls app.boot(env) before dispatching so a
// cron-first cold isolate gets the same plugin wiring as an HTTP request.
export default {
fetch: app.fetch,
scheduled: createScheduledHandler({
// Pass the same plugins the HTTP app uses so cron handlers see the same state.
plugins: () => config.plugins?.register ?? [],
getHooks: getHookSystem,
boot: app.boot,
}),
}

// Mount core app last (catch-all)
app.route('/', coreApp)

export default app
21 changes: 21 additions & 0 deletions packages/core/migrations/038_email_log_observability.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Migration 038: email_log observability columns
-- Adds user_id, context linking, tenant support, and delivery reconciliation
-- columns. All nullable with no defaults (forward-only D1, NULL-safe).

ALTER TABLE email_log ADD COLUMN user_id TEXT;
ALTER TABLE email_log ADD COLUMN context_type TEXT;
ALTER TABLE email_log ADD COLUMN context_id TEXT;
ALTER TABLE email_log ADD COLUMN tenant_id TEXT;
ALTER TABLE email_log ADD COLUMN delivery_state TEXT;
ALTER TABLE email_log ADD COLUMN delivery_synced_at INTEGER;

-- Partial index for reconciliation queries: find rows with a provider_id that
-- haven't had their delivery state resolved yet.
CREATE INDEX IF NOT EXISTS idx_email_log_reconcile
ON email_log (provider, delivery_synced_at)
WHERE provider_id IS NOT NULL AND delivery_state IS NULL;

-- Index for per-user email history queries.
CREATE INDEX IF NOT EXISTS idx_email_log_user_id
ON email_log (user_id)
WHERE user_id IS NOT NULL;
147 changes: 147 additions & 0 deletions packages/core/src/__tests__/plugins/boot-isolate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Tests for T3.2 — bootIsolate extraction, and T3.3 — scheduled() handler wiring.
*/
import { describe, it, expect, afterEach } from 'vitest'
import { createSonicJSApp } from '../../app'
import { createScheduledHandler } from '../../plugins/cron'
import { getHookSystem, hasHookSystem, resetHookSystem, getTypedHooks } from '../../plugins/hooks/hook-system-singleton'
import type { Plugin } from '../../plugins/types'

afterEach(() => {
resetHookSystem()
})

// Minimal fake env (no D1 needed — bootstrap degrades gracefully)
const fakeEnv = {} as Record<string, unknown>

describe('SonicJSApp.boot (T3.2 — bootIsolate)', () => {
it('is exposed as a function on the returned app', () => {
const app = createSonicJSApp()
expect(typeof app.boot).toBe('function')
})

it('calling boot() initializes the hook system (wires plugins)', async () => {
let booted = false
const plugin: Plugin = {
name: 'test-plugin',
version: '0.0.0',
onBoot: async () => { booted = true },
} as Plugin

const app = createSonicJSApp({ plugins: { register: [plugin] } })

expect(booted).toBe(false)
// Call boot directly (simulates cron-first cold isolate)
await app.boot(fakeEnv)
expect(booted).toBe(true)
})

it('boot() is idempotent — calling twice only boots once', async () => {
let bootCount = 0
const plugin: Plugin = {
name: 'counter',
version: '0.0.0',
onBoot: async () => { bootCount++ },
} as Plugin

const app = createSonicJSApp({ plugins: { register: [plugin] } })
await app.boot(fakeEnv)
await app.boot(fakeEnv)
await app.boot(fakeEnv)

expect(bootCount).toBe(1)
})

it('boot() and the HTTP middleware share the same once-guard', async () => {
let bootCount = 0
const plugin: Plugin = {
name: 'counter',
version: '0.0.0',
onBoot: async () => { bootCount++ },
} as Plugin

const app = createSonicJSApp({ plugins: { register: [plugin] } })

// First: call boot directly (cron path)
await app.boot(fakeEnv)
expect(bootCount).toBe(1)

// Then: HTTP request also tries to boot — should be a no-op
await app.request('/health')
expect(bootCount).toBe(1)
})

it('boot() is a no-op when disableAll is true', async () => {
let booted = false
const plugin: Plugin = {
name: 'test-plugin',
version: '0.0.0',
onBoot: async () => { booted = true },
} as Plugin

const app = createSonicJSApp({ plugins: { disableAll: true, register: [plugin] } })
await app.boot(fakeEnv)
expect(booted).toBe(false)
})
})

describe('createScheduledHandler with boot option (T3.3)', () => {
it('calls boot() before dispatching so cron-first isolate has a wired hook bus', async () => {
let hookRan = false
const plugin: Plugin = {
name: 'cron-observer',
version: '0.0.0',
hooks: [{
name: 'auth:registration:completed',
handler: async (d: any) => { hookRan = true; return d },
}],
onBoot: async () => {},
} as Plugin

const app = createSonicJSApp({ plugins: { register: [plugin] } })

// No HTTP request has been made — hook system not yet wired.
// The scheduled handler must call boot() to wire it.
const handler = createScheduledHandler({
plugins: () => config.plugins?.register ?? [],
getHooks: getHookSystem,
boot: app.boot,
})
const config = { plugins: { register: [plugin] } }

await handler({ cron: '*/15 * * * *', scheduledTime: 0 }, fakeEnv)

// Boot ran, hook system is live. Dispatch a test event to verify.
await getTypedHooks().dispatch('auth:registration:completed', { user: { id: 'u', email: 'a@b.com' } })
expect(hookRan).toBe(true)
})

it('skips dispatch when disabled is true', async () => {
const app = createSonicJSApp()
const handler = createScheduledHandler({
plugins: [],
getHooks: getHookSystem,
boot: app.boot,
disabled: true,
})

const result = await handler({ cron: '* * * * *', scheduledTime: 0 }, fakeEnv)
expect(result.invoked).toHaveLength(0)
expect(result.unmatched).toBe(true)
})

it('handler result is unmatched when no plugins declare the fired schedule', async () => {
const app = createSonicJSApp()
await app.boot(fakeEnv) // pre-boot

const handler = createScheduledHandler({
plugins: [],
getHooks: getHookSystem,
boot: app.boot,
})

const result = await handler({ cron: '0 0 * * *', scheduledTime: 0 }, fakeEnv)
expect(result.unmatched).toBe(true)
expect(result.invoked).toHaveLength(0)
})
})
59 changes: 59 additions & 0 deletions packages/core/src/__tests__/plugins/generate-triggers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Tests for T3.4 — wrangler.toml cron trigger codegen.
*/
import { describe, it, expect } from 'vitest'
import { parseCronTriggers, updateWranglerTriggers } from '../../plugins/generate-triggers'

const BASE_TOML = `name = "my-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"
`

const TOML_WITH_TRIGGERS = `name = "my-app"
main = "src/index.ts"

[triggers]
crons = ["*/15 * * * *", "0 0 * * *"]
`

describe('parseCronTriggers', () => {
it('returns empty array when no [triggers] section', () => {
expect(parseCronTriggers(BASE_TOML)).toEqual([])
})

it('parses cron expressions from [triggers] section', () => {
expect(parseCronTriggers(TOML_WITH_TRIGGERS)).toEqual(['*/15 * * * *', '0 0 * * *'])
})

it('returns sorted results', () => {
const toml = `[triggers]\ncrons = ["0 0 * * *", "*/5 * * * *"]\n`
expect(parseCronTriggers(toml)).toEqual(['*/5 * * * *', '0 0 * * *'])
})
})

describe('updateWranglerTriggers', () => {
it('appends [triggers] section when none exists', () => {
const updated = updateWranglerTriggers(BASE_TOML, ['*/15 * * * *'])
expect(updated).toContain('[triggers]')
expect(updated).toContain('"*/15 * * * *"')
})

it('replaces existing [triggers] section', () => {
const updated = updateWranglerTriggers(TOML_WITH_TRIGGERS, ['0 12 * * *'])
expect(updated).toContain('[triggers]')
expect(updated).toContain('"0 12 * * *"')
expect(updated).not.toContain('"*/15 * * * *"')
})

it('removes [triggers] section when schedules is empty', () => {
const updated = updateWranglerTriggers(TOML_WITH_TRIGGERS, [])
expect(updated).not.toContain('[triggers]')
})

it('roundtrip: write then parse returns the same schedules', () => {
const schedules = ['*/15 * * * *', '0 0 1 * *']
const updated = updateWranglerTriggers(BASE_TOML, schedules)
const parsed = parseCronTriggers(updated)
expect(parsed).toEqual(schedules.sort())
})
})
Loading