Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
188110e
feat: add Netlify adapter and queue provider abstraction for multi-pl…
claude Feb 28, 2026
9513a42
feat: complete Netlify adapter with split functions, scheduler, DB su…
claude Feb 28, 2026
992ea0c
docs: add provider plugin architecture plan
claude Feb 28, 2026
2427eba
docs: add Vercel, GCP, and Docker/local provider specs to plan
claude Feb 28, 2026
7dc47e9
docs: add deploy story, difficulty assessment, and tier 3 analysis
claude Feb 28, 2026
35013d9
feat: complete Netlify provider to full plugin interface
claude Mar 1, 2026
9c6a13d
refactor: rename @friggframework/netlify-adapter to @friggframework/p…
claude Mar 1, 2026
0430c59
fix: address PR review feedback on deploy.js and scheduled-sync.js
claude Mar 1, 2026
72e5463
fix: use netlify-background queue provider in worker-background.js
claude Mar 1, 2026
88cf983
feat: add provider resolver to bridge appDefinition.provider to provi…
claude Mar 1, 2026
c870571
feat: wire CLI commands to use appDefinition.provider for provider di…
claude Mar 1, 2026
7b93757
test: add provider dispatch tests for CLI commands
claude Mar 1, 2026
a2a8f01
fix(scheduler): prevent duplicate dispatch via PROCESSING state guard
claude Mar 2, 2026
97831cd
docs: add ADR for multi-provider support architecture
claude Mar 2, 2026
1176a00
fix(scheduler): lazy-load adapters to avoid eager AWS SDK import
claude Mar 3, 2026
302ab9b
fix: route non-AWS providers through provider build in infrastructure.js
claude Mar 3, 2026
9237e31
refactor: extract AWS adapters to provider-aws, move provider-netlify…
claude Mar 3, 2026
023b098
refactor: introduce hexagonal architecture interfaces for AWS decoupling
claude Mar 3, 2026
5949712
refactor!: remove lazy-loading backward compat, require explicit adap…
claude Mar 3, 2026
3d89eac
fix: address review findings from AWS decoupling branch review
claude Mar 3, 2026
21381ca
fix(provider-netlify): move @friggframework/core to peerDependencies
claude Mar 3, 2026
3de4267
chore: update package-lock.json after provider-netlify dep change
claude Mar 3, 2026
079aa2d
fix(providers): use wildcard peer dep for @friggframework/core
claude Mar 3, 2026
29ef032
fix(core): remove provider-aws from core's peerDependencies
claude Mar 3, 2026
a8d08b4
refactor(core): eliminate hardcoded require('@friggframework/provider…
claude Mar 3, 2026
74db90f
refactor(core): remove mongoose dependency, use Prisma and mongodb dr…
d-klotz Mar 3, 2026
e34b741
chore(deps): remove lodash.get, upgrade supertest to v7
claude Mar 3, 2026
077502a
fix(provider-netlify): generate lib/ shims alongside function entry p…
claude Mar 3, 2026
f95ba19
fix(core): address code review findings from PR #546
d-klotz Mar 3, 2026
3dadab6
fix(provider-netlify): fix ONGOING_SYNC detection and webhook route r…
claude Mar 4, 2026
2eed07a
fix(provider-netlify): add @friggframework packages to esbuild externals
claude Mar 4, 2026
8642b58
refactor(core): remove unused expectShallowEqualDbObject
d-klotz Mar 5, 2026
ce82650
refactor(tests): remove password encryption tests
d-klotz Mar 5, 2026
45e06e5
refactor(core): remove unused Entity import
d-klotz Mar 5, 2026
c17a357
feat(core): add AWS SDK client-scheduler dependency
d-klotz Mar 5, 2026
a987d94
Merge pull request #546 from friggframework/refactor/remove-mongoose-…
d-klotz Mar 5, 2026
e7becd9
feat(core, provider-netlify): make app definition statically traceabl…
claude Mar 6, 2026
5ec2975
fix(core): lazy-load MongoDB/DocumentDB implementations in all reposi…
claude Mar 6, 2026
8db2e0d
fix(core): lazy-load Entity (Mongoose model) and DocumentDB implement…
claude Mar 6, 2026
f995374
chore: remove stale build artifact
claude Mar 6, 2026
17e3f18
chore: remove stale prisma layer build artifact
claude Mar 6, 2026
c459392
chore: remove stale prisma layer build artifact
claude Mar 6, 2026
4f3ba52
refactor(core): replace mongodb ObjectId with bson
d-klotz Mar 6, 2026
f9c9ccc
Merge branch 'next' into refactor/remove-mongoose-dependency
d-klotz Mar 6, 2026
67ebb53
refactor(core): remove unused AWS SDK client-scheduler dependency
d-klotz Mar 6, 2026
aac4f40
Merge pull request #547 from friggframework/refactor/remove-mongoose-…
d-klotz Mar 6, 2026
0c9482d
fix(infra): self-heal VPC subnet-route table association drift
d-klotz Mar 6, 2026
1a22160
fix(infra): improve self-healing for VPC subnet associations
d-klotz Mar 6, 2026
90eefb0
test(infra): add self-heal tests for VPC subnet associations
d-klotz Mar 6, 2026
1435f81
test(infra): enhance fallback path tests for VPC subnets
d-klotz Mar 6, 2026
c8ae0ca
style(tests): format test cases for better readability
d-klotz Mar 6, 2026
9740ac8
Merge pull request #548 from friggframework/fix/vpc-subnet-route-tabl…
d-klotz Mar 6, 2026
9660c3e
merge: pull from next branch and resolve all conflicts
claude Mar 7, 2026
d336d2a
fix(core): route backend discovery through loadAppDefinition() for Ne…
claude Mar 9, 2026
2b4f642
chore: sync package-lock.json
claude Mar 10, 2026
b75e1e2
fix: regenerate package-lock.json with missing encoding and iconv-lit…
claude Mar 10, 2026
cd04c85
fix: regenerate package-lock.json from next branch base
claude Mar 10, 2026
dba001a
fix: regenerate package-lock.json with npm 11 for CI compatibility
claude Mar 10, 2026
c40eca4
feat(core): lazy router initialization to eliminate require-time side…
claude Mar 12, 2026
60108f0
fix(core,netlify): resolve Prisma client loading on Netlify deployments
claude Mar 13, 2026
ae2019f
fix(core,netlify): reduce Netlify function bundle size
claude Mar 13, 2026
676b219
fix(core): remove redundant debian-openssl binaryTarget from Prisma s…
claude Mar 13, 2026
69acce5
feat(core): add extension system for DB-backed OAuth credentials
claude Mar 20, 2026
4f9d12a
feat(schemas,devtools): add extensions to app definition schema and v…
claude Mar 20, 2026
ccb5010
feat(extensions): add @friggframework/extension-db-credentials package
claude Mar 20, 2026
6f62ce6
fix: add packages/extensions/* to workspaces and lerna packages
claude Mar 20, 2026
5a3df8f
fix(db-credentials): allow prerelease versions of @friggframework/core
claude Mar 20, 2026
e256e95
fix: regenerate package-lock.json to include db-credentials workspace
claude Mar 20, 2026
b4ca16d
fix(db-credentials): add publishConfig and repo metadata for npm publish
claude Mar 21, 2026
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
10 changes: 9 additions & 1 deletion packages/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ const utils = require('./utils');

// const {Sync } = require('./syncs/model');

const { QueuerUtil } = require('./queues');
const {
QueuerUtil,
QueueProvider,
createQueueProvider,
QUEUE_PROVIDERS,
} = require('./queues');

module.exports = {
// assertions
Expand Down Expand Up @@ -187,6 +192,9 @@ module.exports = {
ModuleFactory,
// queues
QueuerUtil,
QueueProvider,
createQueueProvider,
QUEUE_PROVIDERS,

// utils
...utils,
Expand Down
5 changes: 4 additions & 1 deletion packages/core/infrastructure/scheduler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* Follows hexagonal architecture with interface + adapters pattern.
*
* Providers:
* - eventbridge: AWS EventBridge Scheduler (production)
* - eventbridge: AWS EventBridge Scheduler (production on AWS)
* - netlify: Netlify poll-and-dispatch scheduler (production on Netlify)
* - mock: In-memory mock scheduler (local development)
*/

const { SchedulerServiceInterface } = require('./scheduler-service-interface');
const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter');
const { MockSchedulerAdapter } = require('./mock-scheduler-adapter');
const { NetlifySchedulerAdapter } = require('./netlify-scheduler-adapter');
const {
createSchedulerService,
SCHEDULER_PROVIDERS,
Expand All @@ -25,6 +27,7 @@ module.exports = {
// Adapters
EventBridgeSchedulerAdapter,
MockSchedulerAdapter,
NetlifySchedulerAdapter,

// Factory
createSchedulerService,
Expand Down
173 changes: 173 additions & 0 deletions packages/core/infrastructure/scheduler/netlify-scheduler-adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Netlify Scheduler Adapter
*
* Implements SchedulerServiceInterface for Netlify deployments.
*
* Netlify Scheduled Functions use cron syntax but are defined statically in
* netlify.toml or via the @netlify/functions schedule() helper. They cannot
* be created dynamically at runtime like EventBridge Scheduler.
*
* Strategy for one-time scheduled jobs on Netlify:
* 1. Store the schedule in the database (same as mock adapter pattern)
* 2. A Netlify Scheduled Function runs on a cron interval (e.g., every 5 min)
* 3. The cron function queries for due schedules and dispatches them
* to a background function for execution
*
* This is a "poll-and-dispatch" pattern vs EventBridge's "push" pattern.
* Trade-off: slightly less precise timing (up to cron interval delay),
* but works on any platform without cloud-specific scheduling APIs.
*
* For integrations that need the queue provider to dispatch jobs:
* - queueResourceId maps to the background function URL path
* - Payload is stored and forwarded when the cron fires
*/

const { SchedulerServiceInterface } = require('./scheduler-service-interface');

class NetlifySchedulerAdapter extends SchedulerServiceInterface {
/**
* @param {Object} options
* @param {Object} options.repository - Repository for persisting schedules
* Must implement: save(schedule), delete(scheduleName),
* findByName(scheduleName), findDue(now)
* @param {Object} [options.queueProvider] - Queue provider for dispatching due jobs
*/
constructor(options = {}) {
super();
this.repository = options.repository;
this.queueProvider = options.queueProvider;

if (!this.repository) {
console.warn(
'[NetlifyScheduler] No repository provided. ' +
'Schedules will be stored in memory (lost on function restart). ' +
'Provide a database-backed repository for production use.'
);
this._inMemorySchedules = new Map();
}
}

async scheduleOneTime({
scheduleName,
scheduleAt,
queueResourceId,
payload,
}) {
if (!scheduleName) throw new Error('scheduleName is required');
if (!scheduleAt || !(scheduleAt instanceof Date))
throw new Error('scheduleAt must be a valid Date object');
if (!queueResourceId) throw new Error('queueResourceId is required');

const scheduleData = {
scheduleName,
scheduledAt: scheduleAt.toISOString(),
queueResourceId,
payload,
createdAt: new Date().toISOString(),
state: 'PENDING',
};

if (this.repository) {
await this.repository.save(scheduleData);
} else {
this._inMemorySchedules.set(scheduleName, scheduleData);
}

console.log(
`[NetlifyScheduler] Scheduled: ${scheduleName} for ${scheduleAt.toISOString()}`
);

return {
scheduledJobId: `netlify-schedule-${scheduleName}`,
scheduledAt: scheduleAt.toISOString(),
};
}

async deleteSchedule(scheduleName) {
if (!scheduleName) throw new Error('scheduleName is required');

if (this.repository) {
await this.repository.delete(scheduleName);
} else {
this._inMemorySchedules.delete(scheduleName);
}

console.log(`[NetlifyScheduler] Deleted schedule: ${scheduleName}`);
}

async getScheduleStatus(scheduleName) {
if (!scheduleName) throw new Error('scheduleName is required');

let schedule;
if (this.repository) {
schedule = await this.repository.findByName(scheduleName);
} else {
schedule = this._inMemorySchedules.get(scheduleName);
}

if (!schedule) {
return { exists: false };
}

return {
exists: true,
scheduledAt: schedule.scheduledAt,
state: schedule.state,
};
}

/**
* Process due schedules. Called by the Netlify cron function.
*
* Finds all schedules where scheduledAt <= now, dispatches them
* to the queue provider, and marks them as completed.
*
* @returns {Promise<{ processed: number, errors: number }>}
*/
async processDueSchedules() {
if (!this.repository) {
console.warn(
'[NetlifyScheduler] Cannot process due schedules without a repository'
);
return { processed: 0, errors: 0 };
}

const now = new Date();
const dueSchedules = await this.repository.findDue(now);

let processed = 0;
let errors = 0;

for (const schedule of dueSchedules) {
try {
if (this.queueProvider) {
await this.queueProvider.send(
schedule.payload,
schedule.queueResourceId
);
} else {
console.log(
`[NetlifyScheduler] No queue provider — logging payload for: ${schedule.scheduleName}`,
JSON.stringify(schedule.payload)
);
}

await this.repository.delete(schedule.scheduleName);
processed++;
Comment on lines +141 to +163
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: If queueProvider.send() succeeds but repository.delete() fails (database error, network timeout, etc.), the schedule remains in the database with state='PENDING' and will be re-dispatched on the next cron run, causing duplicate job execution.

Fix: Update the schedule state to 'PROCESSING' before dispatching, then delete after success:

for (const schedule of dueSchedules) {
    try {
        // Mark as processing first
        await this.repository.save({
            ...schedule,
            state: 'PROCESSING'
        });
        
        if (this.queueProvider) {
            await this.queueProvider.send(
                schedule.payload,
                schedule.queueResourceId
            );
        }
        
        // Only delete after successful dispatch
        await this.repository.delete(schedule.scheduleName);
        processed++;
    } catch (error) {
        // Update state to FAILED instead of deleting
        await this.repository.save({
            ...schedule,
            state: 'FAILED'
        }).catch(() => {});
        errors++;
    }
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

} catch (error) {
console.error(
`[NetlifyScheduler] Error processing schedule ${schedule.scheduleName}:`,
error
);
errors++;
}
}

console.log(
`[NetlifyScheduler] Processed ${processed} due schedules, ${errors} errors`
);
return { processed, errors };
}
}

module.exports = { NetlifySchedulerAdapter };
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

const { EventBridgeSchedulerAdapter } = require('./eventbridge-scheduler-adapter');
const { MockSchedulerAdapter } = require('./mock-scheduler-adapter');
const { NetlifySchedulerAdapter } = require('./netlify-scheduler-adapter');

const SCHEDULER_PROVIDERS = {
EVENTBRIDGE: 'eventbridge',
MOCK: 'mock',
NETLIFY: 'netlify',
};

const LOCAL_STAGES = ['dev', 'test', 'local'];
Expand Down Expand Up @@ -44,9 +46,11 @@ function determineProvider() {
* Create a scheduler service instance
*
* @param {Object} options
* @param {string} options.provider - Scheduler provider ('eventbridge' or 'mock')
* @param {string} options.provider - Scheduler provider ('eventbridge', 'mock', or 'netlify')
* @param {string} options.region - AWS region (for EventBridge)
* @param {boolean} options.verbose - Verbose logging (for Mock)
* @param {Object} options.repository - Schedule repository (for Netlify - persists schedules)
* @param {Object} options.queueProvider - Queue provider (for Netlify - dispatches due jobs)
* @returns {SchedulerServiceInterface} Implementation of scheduler interface
*/
function createSchedulerService(options = {}) {
Expand All @@ -61,6 +65,11 @@ function createSchedulerService(options = {}) {
return new MockSchedulerAdapter({
verbose: options.verbose,
});
case SCHEDULER_PROVIDERS.NETLIFY:
return new NetlifySchedulerAdapter({
repository: options.repository,
queueProvider: options.queueProvider,
});
default:
throw new Error(`Unknown scheduler provider: ${provider}`);
}
Expand Down
18 changes: 18 additions & 0 deletions packages/core/prisma-mongodb/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -429,3 +429,21 @@ model ScriptSchedule {
@@index([enabled])
@@map("ScriptSchedule")
}

/// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
/// Used by providers without native one-time scheduling APIs (e.g., Netlify).
/// A cron function queries for due jobs and dispatches them to a queue.
model ScheduledJob {
id String @id @default(auto()) @map("_id") @db.ObjectId
scheduleName String @unique
scheduledAt DateTime
queueResourceId String
payload Json?
state String @default("PENDING")

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([state, scheduledAt])
@@map("ScheduledJob")
}
17 changes: 17 additions & 0 deletions packages/core/prisma-postgresql/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,20 @@ model ScriptSchedule {

@@index([enabled])
}

/// One-time scheduled jobs for poll-and-dispatch scheduling pattern.
/// Used by providers without native one-time scheduling APIs (e.g., Netlify).
/// A cron function queries for due jobs and dispatches them to a queue.
model ScheduledJob {
id Int @id @default(autoincrement())
scheduleName String @unique
scheduledAt DateTime
queueResourceId String
payload Json?
state String @default("PENDING")

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([state, scheduledAt])
}
19 changes: 19 additions & 0 deletions packages/core/queues/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
const { QueuerUtil } = require('./queuer-util');
const { QueueProvider } = require('./queue-provider');
const {
createQueueProvider,
determineProvider,
QUEUE_PROVIDERS,
} = require('./queue-provider-factory');
const {
SqsQueueProvider,
NetlifyBackgroundProvider,
QStashQueueProvider,
} = require('./providers');

module.exports = {
QueuerUtil,
QueueProvider,
createQueueProvider,
determineProvider,
QUEUE_PROVIDERS,
SqsQueueProvider,
NetlifyBackgroundProvider,
QStashQueueProvider,
};
9 changes: 9 additions & 0 deletions packages/core/queues/providers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const { SqsQueueProvider } = require('./sqs-queue-provider');
const { NetlifyBackgroundProvider } = require('./netlify-background-provider');
const { QStashQueueProvider } = require('./qstash-queue-provider');

module.exports = {
SqsQueueProvider,
NetlifyBackgroundProvider,
QStashQueueProvider,
};
Loading
Loading