@meetploy/cli 1.16.0 → 1.17.1

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/dist/index.js CHANGED
@@ -147,6 +147,22 @@ function validateBindings(bindings, bindingType, configFile) {
147
147
  }
148
148
  }
149
149
  }
150
+ function validateCronBindings(bindings, configFile) {
151
+ if (bindings === void 0) {
152
+ return;
153
+ }
154
+ for (const [bindingName, cronExpression] of Object.entries(bindings)) {
155
+ if (!BINDING_NAME_REGEX.test(bindingName)) {
156
+ throw new Error(`Invalid cron binding name '${bindingName}' in ${configFile}. Binding names must be uppercase with underscores (e.g., HOURLY_CLEANUP, DAILY_REPORT)`);
157
+ }
158
+ if (typeof cronExpression !== "string") {
159
+ throw new Error(`Cron binding '${bindingName}' in ${configFile} must have a string value (cron expression)`);
160
+ }
161
+ if (!CRON_EXPRESSION_REGEX.test(cronExpression.trim())) {
162
+ throw new Error(`Invalid cron expression '${cronExpression}' for binding '${bindingName}' in ${configFile}. Must be a valid cron expression with 5 fields (e.g., "0 * * * *", "*/5 * * * *")`);
163
+ }
164
+ }
165
+ }
150
166
  function validateRelativePath(path, fieldName, configFile) {
151
167
  if (path === void 0) {
152
168
  return void 0;
@@ -202,6 +218,8 @@ function validatePloyConfig(config, configFile = "ploy.yaml", options = {}) {
202
218
  validateBindings(config.state, "state", configFile);
203
219
  validateBindings(config.fs, "fs", configFile);
204
220
  validateBindings(config.workflow, "workflow", configFile);
221
+ validateCronBindings(config.cron, configFile);
222
+ validateBindings(config.alarm, "alarm", configFile);
205
223
  if (config.ai !== void 0 && typeof config.ai !== "boolean") {
206
224
  throw new Error(`'ai' in ${configFile} must be a boolean`);
207
225
  }
@@ -277,7 +295,7 @@ function readAndValidatePloyConfigSync(projectDir, configPath, validationOptions
277
295
  return validatePloyConfig(config, configFile, validationOptions);
278
296
  }
279
297
  function hasBindings(config) {
280
- return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.fs ?? config.workflow ?? config.ai ?? config.auth);
298
+ return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.fs ?? config.workflow ?? config.cron ?? config.alarm ?? config.ai ?? config.auth);
281
299
  }
282
300
  function parseDotEnv(content) {
283
301
  const result = {};
@@ -339,7 +357,7 @@ function getWorkerEntryPoint(projectDir, config) {
339
357
  }
340
358
  throw new Error("Could not find worker entry point. Specify 'main' in ploy.yaml");
341
359
  }
342
- var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX;
360
+ var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX, CRON_EXPRESSION_REGEX;
343
361
  var init_ploy_config = __esm({
344
362
  "../tools/dist/ploy-config.js"() {
345
363
  readFileAsync = promisify(readFile$1);
@@ -347,6 +365,7 @@ var init_ploy_config = __esm({
347
365
  DEFAULT_COMPATIBILITY_DATE = "2025-04-02";
348
366
  BINDING_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
349
367
  RESOURCE_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
368
+ CRON_EXPRESSION_REGEX = /^(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)$/;
350
369
  }
351
370
  });
352
371
 
@@ -379,6 +398,72 @@ var init_cli = __esm({
379
398
  }
380
399
  });
381
400
 
401
+ // ../emulator/dist/runtime/alarm-runtime.js
402
+ var ALARM_RUNTIME_CODE;
403
+ var init_alarm_runtime = __esm({
404
+ "../emulator/dist/runtime/alarm-runtime.js"() {
405
+ ALARM_RUNTIME_CODE = `
406
+ export function initializeAlarm(alarmName, serviceUrl) {
407
+ function parseOptions(options) {
408
+ if (options === undefined || options === null) {
409
+ return { payload: undefined, intervalMs: undefined };
410
+ }
411
+ if (typeof options === "object" && options !== null && ("payload" in options || "intervalMs" in options)) {
412
+ return { payload: options.payload, intervalMs: options.intervalMs };
413
+ }
414
+ return { payload: options, intervalMs: undefined };
415
+ }
416
+
417
+ return {
418
+ async set(id, scheduledTime, options) {
419
+ const ts = typeof scheduledTime === "number" ? scheduledTime : scheduledTime.getTime();
420
+ const { payload, intervalMs } = parseOptions(options);
421
+ const response = await fetch(serviceUrl + "/alarm/set", {
422
+ method: "POST",
423
+ headers: { "Content-Type": "application/json" },
424
+ body: JSON.stringify({ alarmName, id, scheduledTime: ts, payload, intervalMs }),
425
+ });
426
+
427
+ if (!response.ok) {
428
+ const errorText = await response.text();
429
+ throw new Error("Alarm set failed: " + errorText);
430
+ }
431
+ },
432
+
433
+ async get(id) {
434
+ const response = await fetch(serviceUrl + "/alarm/get", {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify({ alarmName, id }),
438
+ });
439
+
440
+ if (!response.ok) {
441
+ const errorText = await response.text();
442
+ throw new Error("Alarm get failed: " + errorText);
443
+ }
444
+
445
+ const result = await response.json();
446
+ return result.alarm ?? null;
447
+ },
448
+
449
+ async delete(id) {
450
+ const response = await fetch(serviceUrl + "/alarm/delete", {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json" },
453
+ body: JSON.stringify({ alarmName, id }),
454
+ });
455
+
456
+ if (!response.ok) {
457
+ const errorText = await response.text();
458
+ throw new Error("Alarm delete failed: " + errorText);
459
+ }
460
+ },
461
+ };
462
+ }
463
+ `;
464
+ }
465
+ });
466
+
382
467
  // ../emulator/dist/runtime/cache-runtime.js
383
468
  var CACHE_RUNTIME_CODE;
384
469
  var init_cache_runtime = __esm({
@@ -1252,6 +1337,12 @@ function generateWrapperCode(config, mockServiceUrl, envVars) {
1252
1337
  bindings.push(` ${bindingName}: initializeWorkflow("${workflowName}", "${mockServiceUrl}"),`);
1253
1338
  }
1254
1339
  }
1340
+ if (config.alarm) {
1341
+ imports.push('import { initializeAlarm } from "__ploy_alarm_runtime__";');
1342
+ for (const [bindingName, alarmName] of Object.entries(config.alarm)) {
1343
+ bindings.push(` ${bindingName}: initializeAlarm("${alarmName}", "${mockServiceUrl}"),`);
1344
+ }
1345
+ }
1255
1346
  imports.push('import userWorker from "__ploy_user_worker__";');
1256
1347
  const workflowHandlerCode = config.workflow ? `
1257
1348
  // Handle workflow execution requests
@@ -1280,6 +1371,63 @@ function generateWrapperCode(config, mockServiceUrl, envVars) {
1280
1371
  }
1281
1372
  }
1282
1373
  }` : "";
1374
+ const cronHandlerCode = config.cron ? `
1375
+ // Handle cron trigger delivery
1376
+ if (request.headers.get("X-Ploy-Cron-Trigger") === "true") {
1377
+ const cronExpression = request.headers.get("X-Ploy-Cron-Expression");
1378
+ const scheduledTime = parseInt(request.headers.get("X-Ploy-Cron-Scheduled-Time") || String(Date.now()), 10);
1379
+
1380
+ if (cronExpression && userWorker.scheduled) {
1381
+ let noRetryFlag = false;
1382
+ const event = {
1383
+ cron: cronExpression,
1384
+ scheduledTime,
1385
+ noRetry() { noRetryFlag = true; }
1386
+ };
1387
+ try {
1388
+ await userWorker.scheduled(event, injectedEnv, ctx);
1389
+ return new Response(JSON.stringify({ success: true, noRetry: noRetryFlag }), {
1390
+ headers: { "Content-Type": "application/json" }
1391
+ });
1392
+ } catch (error) {
1393
+ return new Response(JSON.stringify({ success: false, error: String(error) }), {
1394
+ status: 500,
1395
+ headers: { "Content-Type": "application/json" }
1396
+ });
1397
+ }
1398
+ }
1399
+
1400
+ return new Response(JSON.stringify({ success: false, error: "No scheduled handler defined" }), {
1401
+ status: 404,
1402
+ headers: { "Content-Type": "application/json" }
1403
+ });
1404
+ }` : "";
1405
+ const alarmHandlerCode = config.alarm ? `
1406
+ // Handle alarm fire delivery
1407
+ if (request.headers.get("X-Ploy-Alarm-Fire") === "true") {
1408
+ const alarmId = request.headers.get("X-Ploy-Alarm-Id");
1409
+ const alarmName = request.headers.get("X-Ploy-Alarm-Name");
1410
+ const alarmTime = request.headers.get("X-Ploy-Alarm-Time");
1411
+ if (alarmId && userWorker.alarm) {
1412
+ const body = await request.json().catch(() => ({}));
1413
+ try {
1414
+ await userWorker.alarm({
1415
+ id: alarmId,
1416
+ scheduledTime: parseInt(alarmTime || "0", 10),
1417
+ payload: body.payload,
1418
+ intervalMs: body.intervalMs
1419
+ }, injectedEnv, ctx);
1420
+ return new Response(JSON.stringify({ success: true }), {
1421
+ headers: { "Content-Type": "application/json" }
1422
+ });
1423
+ } catch (error) {
1424
+ return new Response(JSON.stringify({ success: false, error: String(error) }), {
1425
+ status: 500,
1426
+ headers: { "Content-Type": "application/json" }
1427
+ });
1428
+ }
1429
+ }
1430
+ }` : "";
1283
1431
  const queueHandlerCode = config.queue ? `
1284
1432
  // Handle queue message delivery
1285
1433
  if (request.headers.get("X-Ploy-Queue-Delivery") === "true") {
@@ -1323,6 +1471,8 @@ ${bindings.join("\n")}
1323
1471
  export default {
1324
1472
  async fetch(request, env, ctx) {
1325
1473
  const injectedEnv = { ...env, ...ployBindings };${envVarsCode}
1474
+ ${cronHandlerCode}
1475
+ ${alarmHandlerCode}
1326
1476
  ${workflowHandlerCode}
1327
1477
  ${queueHandlerCode}
1328
1478
 
@@ -1370,6 +1520,10 @@ function createRuntimePlugin(_config) {
1370
1520
  path: "__ploy_workflow_runtime__",
1371
1521
  namespace: "ploy-runtime"
1372
1522
  }));
1523
+ build2.onResolve({ filter: /^__ploy_alarm_runtime__$/ }, () => ({
1524
+ path: "__ploy_alarm_runtime__",
1525
+ namespace: "ploy-runtime"
1526
+ }));
1373
1527
  build2.onLoad({ filter: /^__ploy_db_runtime__$/, namespace: "ploy-runtime" }, () => ({
1374
1528
  contents: DB_RUNTIME_CODE,
1375
1529
  loader: "ts"
@@ -1394,6 +1548,10 @@ function createRuntimePlugin(_config) {
1394
1548
  contents: WORKFLOW_RUNTIME_CODE,
1395
1549
  loader: "ts"
1396
1550
  }));
1551
+ build2.onLoad({ filter: /^__ploy_alarm_runtime__$/, namespace: "ploy-runtime" }, () => ({
1552
+ contents: ALARM_RUNTIME_CODE,
1553
+ loader: "ts"
1554
+ }));
1397
1555
  }
1398
1556
  };
1399
1557
  }
@@ -1430,6 +1588,7 @@ async function bundleWorker(options) {
1430
1588
  var NODE_BUILTINS;
1431
1589
  var init_bundler = __esm({
1432
1590
  "../emulator/dist/bundler/bundler.js"() {
1591
+ init_alarm_runtime();
1433
1592
  init_cache_runtime();
1434
1593
  init_db_runtime();
1435
1594
  init_fs_runtime();
@@ -1693,6 +1852,306 @@ var init_workerd_config = __esm({
1693
1852
  "../emulator/dist/config/workerd-config.js"() {
1694
1853
  }
1695
1854
  });
1855
+
1856
+ // ../emulator/dist/services/alarm-service.js
1857
+ function createAlarmHandlers(db) {
1858
+ const setHandler = async (c) => {
1859
+ try {
1860
+ const body = await c.req.json();
1861
+ const { alarmName, id, scheduledTime, payload, intervalMs } = body;
1862
+ const now = Math.floor(Date.now() / 1e3);
1863
+ const scheduledTimeSec = Math.floor(scheduledTime / 1e3);
1864
+ db.prepare(`INSERT OR REPLACE INTO alarm_entries (id, alarm_name, scheduled_time, payload, interval_ms, status, created_at)
1865
+ VALUES (?, ?, ?, ?, ?, 'pending', ?)`).run(id, alarmName, scheduledTimeSec, payload !== void 0 ? JSON.stringify(payload) : null, intervalMs ?? null, now);
1866
+ return c.json({ success: true });
1867
+ } catch (err) {
1868
+ const message = err instanceof Error ? err.message : String(err);
1869
+ return c.json({ success: false, error: message }, 500);
1870
+ }
1871
+ };
1872
+ const getHandler = async (c) => {
1873
+ try {
1874
+ const body = await c.req.json();
1875
+ const { alarmName, id } = body;
1876
+ const row = db.prepare(`SELECT id, scheduled_time, payload, interval_ms FROM alarm_entries
1877
+ WHERE alarm_name = ? AND id = ? AND status = 'pending'`).get(alarmName, id);
1878
+ if (!row) {
1879
+ return c.json({ alarm: null });
1880
+ }
1881
+ return c.json({
1882
+ alarm: {
1883
+ id: row.id,
1884
+ scheduledTime: row.scheduled_time * 1e3,
1885
+ payload: row.payload ? JSON.parse(row.payload) : void 0,
1886
+ intervalMs: row.interval_ms ?? void 0
1887
+ }
1888
+ });
1889
+ } catch (err) {
1890
+ const message = err instanceof Error ? err.message : String(err);
1891
+ return c.json({ success: false, error: message }, 500);
1892
+ }
1893
+ };
1894
+ const deleteHandler = async (c) => {
1895
+ try {
1896
+ const body = await c.req.json();
1897
+ const { alarmName, id } = body;
1898
+ db.prepare(`DELETE FROM alarm_entries WHERE alarm_name = ? AND id = ?`).run(alarmName, id);
1899
+ return c.json({ success: true });
1900
+ } catch (err) {
1901
+ const message = err instanceof Error ? err.message : String(err);
1902
+ return c.json({ success: false, error: message }, 500);
1903
+ }
1904
+ };
1905
+ return {
1906
+ setHandler,
1907
+ getHandler,
1908
+ deleteHandler
1909
+ };
1910
+ }
1911
+ function createAlarmProcessor(db, workerUrl) {
1912
+ let interval = null;
1913
+ async function processAlarms() {
1914
+ const now = Math.floor(Date.now() / 1e3);
1915
+ const rows = db.prepare(`SELECT id, alarm_name, scheduled_time, payload, interval_ms
1916
+ FROM alarm_entries
1917
+ WHERE status = 'pending' AND scheduled_time <= ?
1918
+ ORDER BY scheduled_time ASC
1919
+ LIMIT 50`).all(now);
1920
+ for (const row of rows) {
1921
+ const startTime = Date.now();
1922
+ try {
1923
+ const response = await fetch(workerUrl, {
1924
+ method: "POST",
1925
+ headers: {
1926
+ "Content-Type": "application/json",
1927
+ "X-Ploy-Alarm-Fire": "true",
1928
+ "X-Ploy-Alarm-Id": row.id,
1929
+ "X-Ploy-Alarm-Name": row.alarm_name,
1930
+ "X-Ploy-Alarm-Time": String(row.scheduled_time * 1e3)
1931
+ },
1932
+ body: JSON.stringify({
1933
+ scheduledTime: row.scheduled_time * 1e3,
1934
+ payload: row.payload ? JSON.parse(row.payload) : void 0,
1935
+ intervalMs: row.interval_ms ?? void 0
1936
+ })
1937
+ });
1938
+ const durationMs = Date.now() - startTime;
1939
+ if (response.ok) {
1940
+ if (row.interval_ms) {
1941
+ const nextTimeSec = row.scheduled_time + Math.floor(row.interval_ms / 1e3);
1942
+ db.prepare(`UPDATE alarm_entries SET scheduled_time = ? WHERE id = ? AND alarm_name = ?`).run(nextTimeSec, row.id, row.alarm_name);
1943
+ } else {
1944
+ db.prepare(`UPDATE alarm_entries SET status = 'fired' WHERE id = ? AND alarm_name = ?`).run(row.id, row.alarm_name);
1945
+ }
1946
+ db.prepare(`INSERT INTO alarm_executions (alarm_id, alarm_name, scheduled_time, status, duration_ms, created_at)
1947
+ VALUES (?, ?, ?, 'completed', ?, ?)`).run(row.id, row.alarm_name, row.scheduled_time, durationMs, now);
1948
+ } else {
1949
+ db.prepare(`INSERT INTO alarm_executions (alarm_id, alarm_name, scheduled_time, status, error, duration_ms, created_at)
1950
+ VALUES (?, ?, ?, 'failed', ?, ?, ?)`).run(row.id, row.alarm_name, row.scheduled_time, `HTTP ${String(response.status)}`, durationMs, now);
1951
+ db.prepare(`UPDATE alarm_entries SET scheduled_time = ? WHERE id = ? AND alarm_name = ?`).run(now + 5, row.id, row.alarm_name);
1952
+ }
1953
+ } catch (err) {
1954
+ const durationMs = Date.now() - startTime;
1955
+ const errorMessage = err instanceof Error ? err.message : String(err);
1956
+ db.prepare(`INSERT INTO alarm_executions (alarm_id, alarm_name, scheduled_time, status, error, duration_ms, created_at)
1957
+ VALUES (?, ?, ?, 'failed', ?, ?, ?)`).run(row.id, row.alarm_name, row.scheduled_time, errorMessage, durationMs, now);
1958
+ db.prepare(`UPDATE alarm_entries SET scheduled_time = ? WHERE id = ? AND alarm_name = ?`).run(now + 5, row.id, row.alarm_name);
1959
+ }
1960
+ }
1961
+ }
1962
+ return {
1963
+ start() {
1964
+ if (interval) {
1965
+ return;
1966
+ }
1967
+ interval = setInterval(() => {
1968
+ processAlarms().catch(() => {
1969
+ });
1970
+ }, 1e3);
1971
+ },
1972
+ stop() {
1973
+ if (interval) {
1974
+ clearInterval(interval);
1975
+ interval = null;
1976
+ }
1977
+ }
1978
+ };
1979
+ }
1980
+ var init_alarm_service = __esm({
1981
+ "../emulator/dist/services/alarm-service.js"() {
1982
+ }
1983
+ });
1984
+ function parseCronField(field, min, max) {
1985
+ const values = /* @__PURE__ */ new Set();
1986
+ for (const part of field.split(",")) {
1987
+ const trimmed = part.trim();
1988
+ if (trimmed === "*") {
1989
+ for (let i = min; i <= max; i++) {
1990
+ values.add(i);
1991
+ }
1992
+ continue;
1993
+ }
1994
+ const stepMatch = trimmed.match(/^(.+)\/(\d+)$/);
1995
+ if (stepMatch) {
1996
+ const step = parseInt(stepMatch[2], 10);
1997
+ const base = stepMatch[1];
1998
+ let start = min;
1999
+ let end = max;
2000
+ if (base !== "*") {
2001
+ const rangeMatch2 = base.match(/^(\d+)-(\d+)$/);
2002
+ if (rangeMatch2) {
2003
+ start = parseInt(rangeMatch2[1], 10);
2004
+ end = parseInt(rangeMatch2[2], 10);
2005
+ } else {
2006
+ start = parseInt(base, 10);
2007
+ }
2008
+ }
2009
+ if (start < min || start > max || end < min || end > max) {
2010
+ throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
2011
+ }
2012
+ for (let i = start; i <= end; i += step) {
2013
+ values.add(i);
2014
+ }
2015
+ continue;
2016
+ }
2017
+ const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
2018
+ if (rangeMatch) {
2019
+ const start = parseInt(rangeMatch[1], 10);
2020
+ const end = parseInt(rangeMatch[2], 10);
2021
+ if (start < min || start > max || end < min || end > max) {
2022
+ throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
2023
+ }
2024
+ for (let i = start; i <= end; i++) {
2025
+ values.add(i);
2026
+ }
2027
+ continue;
2028
+ }
2029
+ const num = parseInt(trimmed, 10);
2030
+ if (isNaN(num)) {
2031
+ throw new Error(`Invalid cron field value: '${trimmed}' (expected number, range, step, or wildcard)`);
2032
+ }
2033
+ if (num < min || num > max) {
2034
+ throw new Error(`Cron field value ${num} is out of range (${min}-${max})`);
2035
+ }
2036
+ values.add(num);
2037
+ }
2038
+ return { values };
2039
+ }
2040
+ function parseCronExpression(expression) {
2041
+ const parts = expression.trim().split(/\s+/);
2042
+ if (parts.length !== 5) {
2043
+ throw new Error(`Invalid cron expression: ${expression} (expected 5 fields)`);
2044
+ }
2045
+ return [
2046
+ parseCronField(parts[0], 0, 59),
2047
+ parseCronField(parts[1], 0, 23),
2048
+ parseCronField(parts[2], 1, 31),
2049
+ parseCronField(parts[3], 1, 12),
2050
+ parseCronField(parts[4], 0, 6)
2051
+ ];
2052
+ }
2053
+ function cronMatchesDate(fields, date) {
2054
+ const minute = date.getMinutes();
2055
+ const hour = date.getHours();
2056
+ const dayOfMonth = date.getDate();
2057
+ const month = date.getMonth() + 1;
2058
+ const dayOfWeek = date.getDay();
2059
+ return fields[0].values.has(minute) && fields[1].values.has(hour) && fields[2].values.has(dayOfMonth) && fields[3].values.has(month) && fields[4].values.has(dayOfWeek);
2060
+ }
2061
+ function createCronScheduler(db, cronBindings, workerUrl) {
2062
+ let interval = null;
2063
+ const parsedCrons = /* @__PURE__ */ new Map();
2064
+ let lastCheckedMinute = -1;
2065
+ for (const [name, expression] of Object.entries(cronBindings)) {
2066
+ try {
2067
+ const fields = parseCronExpression(expression);
2068
+ parsedCrons.set(name, { expression, fields });
2069
+ } catch (err) {
2070
+ error(`Failed to parse cron expression for '${name}': ${err instanceof Error ? err.message : String(err)}`);
2071
+ }
2072
+ }
2073
+ async function checkCrons() {
2074
+ const now = /* @__PURE__ */ new Date();
2075
+ const currentMinute = Math.floor(now.getTime() / 6e4);
2076
+ if (currentMinute === lastCheckedMinute) {
2077
+ return;
2078
+ }
2079
+ lastCheckedMinute = currentMinute;
2080
+ for (const [name, cron] of parsedCrons) {
2081
+ if (cronMatchesDate(cron.fields, now)) {
2082
+ await triggerScheduledHandler(db, name, cron.expression, now, workerUrl);
2083
+ }
2084
+ }
2085
+ }
2086
+ return {
2087
+ start() {
2088
+ if (interval) {
2089
+ return;
2090
+ }
2091
+ log(`Cron scheduler started with ${parsedCrons.size} trigger(s)`);
2092
+ for (const [name, cron] of parsedCrons) {
2093
+ log(` ${name}: ${cron.expression}`);
2094
+ }
2095
+ interval = setInterval(() => {
2096
+ checkCrons().catch((err) => {
2097
+ error(`Cron check error: ${err instanceof Error ? err.message : String(err)}`);
2098
+ });
2099
+ }, 15e3);
2100
+ checkCrons().catch(() => {
2101
+ });
2102
+ },
2103
+ stop() {
2104
+ if (interval) {
2105
+ clearInterval(interval);
2106
+ interval = null;
2107
+ }
2108
+ }
2109
+ };
2110
+ }
2111
+ async function triggerScheduledHandler(db, cronName, cronExpression, triggerTime, workerUrl) {
2112
+ const executionId = randomUUID();
2113
+ const now = Math.floor(Date.now() / 1e3);
2114
+ db.prepare(`INSERT INTO cron_executions (id, cron_name, cron_expression, status, triggered_at)
2115
+ VALUES (?, ?, ?, 'running', ?)`).run(executionId, cronName, cronExpression, now);
2116
+ log(`[cron] Triggering '${cronName}' (${cronExpression}) execution=${executionId}`);
2117
+ try {
2118
+ const response = await fetch(workerUrl, {
2119
+ method: "POST",
2120
+ headers: {
2121
+ "Content-Type": "application/json",
2122
+ "X-Ploy-Cron-Trigger": "true",
2123
+ "X-Ploy-Cron-Name": cronName,
2124
+ "X-Ploy-Cron-Expression": cronExpression,
2125
+ "X-Ploy-Cron-Execution-Id": executionId,
2126
+ "X-Ploy-Cron-Scheduled-Time": String(triggerTime.getTime())
2127
+ },
2128
+ body: JSON.stringify({
2129
+ cron: cronExpression,
2130
+ scheduledTime: triggerTime.getTime()
2131
+ })
2132
+ });
2133
+ const completedAt = Math.floor(Date.now() / 1e3);
2134
+ const durationMs = Date.now() - triggerTime.getTime();
2135
+ if (response.ok) {
2136
+ db.prepare(`UPDATE cron_executions SET status = 'completed', completed_at = ?, duration_ms = ? WHERE id = ?`).run(completedAt, durationMs, executionId);
2137
+ log(`[cron] '${cronName}' completed successfully (${durationMs}ms)`);
2138
+ } else {
2139
+ const errorText = await response.text();
2140
+ db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, duration_ms = ?, error = ? WHERE id = ?`).run(completedAt, durationMs, errorText, executionId);
2141
+ error(`[cron] '${cronName}' failed: ${errorText}`);
2142
+ }
2143
+ } catch (err) {
2144
+ const completedAt = Math.floor(Date.now() / 1e3);
2145
+ const errorMessage = err instanceof Error ? err.message : String(err);
2146
+ db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, error = ? WHERE id = ?`).run(completedAt, errorMessage, executionId);
2147
+ error(`[cron] '${cronName}' error: ${errorMessage}`);
2148
+ }
2149
+ }
2150
+ var init_cron_service = __esm({
2151
+ "../emulator/dist/services/cron-service.js"() {
2152
+ init_logger();
2153
+ }
2154
+ });
1696
2155
  function getProjectHash(projectDir) {
1697
2156
  return createHash("sha256").update(projectDir).digest("hex").slice(0, 12);
1698
2157
  }
@@ -2035,6 +2494,8 @@ function createDashboardRoutes(app, dbManager2, config) {
2035
2494
  state: config.state,
2036
2495
  fs: config.fs,
2037
2496
  workflow: config.workflow,
2497
+ cron: config.cron,
2498
+ alarm: config.alarm,
2038
2499
  auth: config.auth
2039
2500
  });
2040
2501
  });
@@ -2559,6 +3020,123 @@ function createDashboardRoutes(app, dbManager2, config) {
2559
3020
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2560
3021
  }
2561
3022
  });
3023
+ app.get("/api/cron/:binding/executions", (c) => {
3024
+ const binding = c.req.param("binding");
3025
+ const cronExpression = config.cron?.[binding];
3026
+ const limit = parseInt(c.req.query("limit") ?? "20", 10);
3027
+ if (!cronExpression) {
3028
+ return c.json({ error: "Cron binding not found" }, 404);
3029
+ }
3030
+ try {
3031
+ const db = dbManager2.emulatorDb;
3032
+ const executions = db.prepare(`SELECT id, cron_name, cron_expression, status, error, duration_ms, triggered_at, completed_at, created_at
3033
+ FROM cron_executions
3034
+ WHERE cron_name = ?
3035
+ ORDER BY triggered_at DESC
3036
+ LIMIT ?`).all(binding, limit);
3037
+ return c.json({
3038
+ cronExpression,
3039
+ executions: executions.map((e) => ({
3040
+ id: e.id,
3041
+ status: e.status.toUpperCase(),
3042
+ error: e.error,
3043
+ durationMs: e.duration_ms,
3044
+ triggeredAt: new Date(e.triggered_at * 1e3).toISOString(),
3045
+ completedAt: e.completed_at ? new Date(e.completed_at * 1e3).toISOString() : null,
3046
+ createdAt: new Date(e.created_at * 1e3).toISOString()
3047
+ }))
3048
+ });
3049
+ } catch (err) {
3050
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
3051
+ }
3052
+ });
3053
+ app.get("/api/alarm/:binding/entries", (c) => {
3054
+ const binding = c.req.param("binding");
3055
+ const alarmName = config.alarm?.[binding];
3056
+ const limit = parseInt(c.req.query("limit") ?? "20", 10);
3057
+ const offset = parseInt(c.req.query("offset") ?? "0", 10);
3058
+ if (!alarmName) {
3059
+ return c.json({ error: "Alarm binding not found" }, 404);
3060
+ }
3061
+ try {
3062
+ const db = dbManager2.emulatorDb;
3063
+ const total = db.prepare(`SELECT COUNT(*) as count FROM alarm_entries WHERE alarm_name = ?`).get(alarmName).count;
3064
+ const entries = db.prepare(`SELECT id, scheduled_time, payload, interval_ms, status, created_at
3065
+ FROM alarm_entries
3066
+ WHERE alarm_name = ?
3067
+ ORDER BY scheduled_time DESC
3068
+ LIMIT ? OFFSET ?`).all(alarmName, limit, offset);
3069
+ return c.json({
3070
+ entries: entries.map((e) => ({
3071
+ id: e.id,
3072
+ scheduledTime: new Date(e.scheduled_time * 1e3).toISOString(),
3073
+ payload: e.payload ? JSON.parse(e.payload) : null,
3074
+ intervalMs: e.interval_ms,
3075
+ status: e.status.toUpperCase(),
3076
+ createdAt: new Date(e.created_at * 1e3).toISOString()
3077
+ })),
3078
+ total,
3079
+ limit,
3080
+ offset
3081
+ });
3082
+ } catch (err) {
3083
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
3084
+ }
3085
+ });
3086
+ app.get("/api/alarm/:binding/metrics", (c) => {
3087
+ const binding = c.req.param("binding");
3088
+ const alarmName = config.alarm?.[binding];
3089
+ if (!alarmName) {
3090
+ return c.json({ error: "Alarm binding not found" }, 404);
3091
+ }
3092
+ try {
3093
+ const db = dbManager2.emulatorDb;
3094
+ const metrics = { pending: 0, fired: 0 };
3095
+ const statusCounts = db.prepare(`SELECT status, COUNT(*) as count
3096
+ FROM alarm_entries
3097
+ WHERE alarm_name = ?
3098
+ GROUP BY status`).all(alarmName);
3099
+ for (const row of statusCounts) {
3100
+ if (row.status === "pending") {
3101
+ metrics.pending = row.count;
3102
+ } else if (row.status === "fired") {
3103
+ metrics.fired = row.count;
3104
+ }
3105
+ }
3106
+ return c.json({ metrics });
3107
+ } catch (err) {
3108
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
3109
+ }
3110
+ });
3111
+ app.get("/api/alarm/:binding/executions", (c) => {
3112
+ const binding = c.req.param("binding");
3113
+ const alarmName = config.alarm?.[binding];
3114
+ const limit = parseInt(c.req.query("limit") ?? "20", 10);
3115
+ if (!alarmName) {
3116
+ return c.json({ error: "Alarm binding not found" }, 404);
3117
+ }
3118
+ try {
3119
+ const db = dbManager2.emulatorDb;
3120
+ const executions = db.prepare(`SELECT id, alarm_id, scheduled_time, status, error, duration_ms, created_at
3121
+ FROM alarm_executions
3122
+ WHERE alarm_name = ?
3123
+ ORDER BY created_at DESC
3124
+ LIMIT ?`).all(alarmName, limit);
3125
+ return c.json({
3126
+ executions: executions.map((e) => ({
3127
+ id: e.id,
3128
+ alarmId: e.alarm_id,
3129
+ scheduledTime: new Date(e.scheduled_time * 1e3).toISOString(),
3130
+ status: e.status.toUpperCase(),
3131
+ error: e.error,
3132
+ durationMs: e.duration_ms,
3133
+ createdAt: new Date(e.created_at * 1e3).toISOString()
3134
+ }))
3135
+ });
3136
+ } catch (err) {
3137
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
3138
+ }
3139
+ });
2562
3140
  if (hasDashboard) {
2563
3141
  app.get("/assets/*", (c) => {
2564
3142
  const path = c.req.path;
@@ -3269,6 +3847,12 @@ async function startMockServer(dbManager2, config, options = {}) {
3269
3847
  app.post("/fs/delete", fsHandlers.deleteHandler);
3270
3848
  app.post("/fs/list", fsHandlers.listHandler);
3271
3849
  }
3850
+ if (config.alarm) {
3851
+ const alarmHandlers = createAlarmHandlers(dbManager2.emulatorDb);
3852
+ app.post("/alarm/set", alarmHandlers.setHandler);
3853
+ app.post("/alarm/get", alarmHandlers.getHandler);
3854
+ app.post("/alarm/delete", alarmHandlers.deleteHandler);
3855
+ }
3272
3856
  if (config.auth) {
3273
3857
  const authHandlers = createAuthHandlers(dbManager2.emulatorDb);
3274
3858
  app.post("/auth/signup", authHandlers.signupHandler);
@@ -3301,6 +3885,7 @@ var DEFAULT_MOCK_SERVER_PORT;
3301
3885
  var init_mock_server = __esm({
3302
3886
  "../emulator/dist/services/mock-server.js"() {
3303
3887
  init_paths();
3888
+ init_alarm_service();
3304
3889
  init_auth_service();
3305
3890
  init_cache_service();
3306
3891
  init_dashboard_routes();
@@ -3463,6 +4048,52 @@ CREATE TABLE IF NOT EXISTS fs_entries (
3463
4048
  created_at INTEGER DEFAULT (strftime('%s', 'now')),
3464
4049
  PRIMARY KEY (fs_name, key)
3465
4050
  );
4051
+
4052
+ -- Cron executions table
4053
+ CREATE TABLE IF NOT EXISTS cron_executions (
4054
+ id TEXT PRIMARY KEY,
4055
+ cron_name TEXT NOT NULL,
4056
+ cron_expression TEXT NOT NULL,
4057
+ status TEXT DEFAULT 'running',
4058
+ error TEXT,
4059
+ duration_ms INTEGER,
4060
+ triggered_at INTEGER NOT NULL,
4061
+ completed_at INTEGER,
4062
+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
4063
+ );
4064
+
4065
+ CREATE INDEX IF NOT EXISTS idx_cron_executions_name
4066
+ ON cron_executions(cron_name, triggered_at);
4067
+
4068
+ -- Alarm entries table (durable timers, optionally recurring)
4069
+ CREATE TABLE IF NOT EXISTS alarm_entries (
4070
+ id TEXT NOT NULL,
4071
+ alarm_name TEXT NOT NULL,
4072
+ scheduled_time INTEGER NOT NULL,
4073
+ payload TEXT,
4074
+ interval_ms INTEGER,
4075
+ status TEXT DEFAULT 'pending',
4076
+ created_at INTEGER DEFAULT (strftime('%s', 'now')),
4077
+ PRIMARY KEY (alarm_name, id)
4078
+ );
4079
+
4080
+ CREATE INDEX IF NOT EXISTS idx_alarm_entries_status_time
4081
+ ON alarm_entries(status, scheduled_time);
4082
+
4083
+ -- Alarm execution log
4084
+ CREATE TABLE IF NOT EXISTS alarm_executions (
4085
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4086
+ alarm_id TEXT NOT NULL,
4087
+ alarm_name TEXT NOT NULL,
4088
+ scheduled_time INTEGER NOT NULL,
4089
+ status TEXT DEFAULT 'pending',
4090
+ error TEXT,
4091
+ duration_ms INTEGER,
4092
+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
4093
+ );
4094
+
4095
+ CREATE INDEX IF NOT EXISTS idx_alarm_executions_name
4096
+ ON alarm_executions(alarm_name, created_at);
3466
4097
  `;
3467
4098
  }
3468
4099
  });
@@ -3479,6 +4110,8 @@ var init_emulator = __esm({
3479
4110
  init_env();
3480
4111
  init_ploy_config2();
3481
4112
  init_workerd_config();
4113
+ init_alarm_service();
4114
+ init_cron_service();
3482
4115
  init_mock_server();
3483
4116
  init_queue_service();
3484
4117
  init_logger();
@@ -3494,6 +4127,8 @@ var init_emulator = __esm({
3494
4127
  workerdProcess = null;
3495
4128
  fileWatcher = null;
3496
4129
  queueProcessor = null;
4130
+ cronScheduler = null;
4131
+ alarmProcessor = null;
3497
4132
  resolvedEnvVars = {};
3498
4133
  constructor(options = {}) {
3499
4134
  const port = options.port ?? 8787;
@@ -3561,6 +4196,18 @@ var init_emulator = __esm({
3561
4196
  this.queueProcessor.start();
3562
4197
  debug("Queue processor started", this.options.verbose);
3563
4198
  }
4199
+ if (this.config.cron) {
4200
+ const workerUrl2 = `http://${this.options.host}:${String(this.options.port)}`;
4201
+ this.cronScheduler = createCronScheduler(this.dbManager.emulatorDb, this.config.cron, workerUrl2);
4202
+ this.cronScheduler.start();
4203
+ debug("Cron scheduler started", this.options.verbose);
4204
+ }
4205
+ if (this.config.alarm) {
4206
+ const workerUrl2 = `http://${this.options.host}:${String(this.options.port)}`;
4207
+ this.alarmProcessor = createAlarmProcessor(this.dbManager.emulatorDb, workerUrl2);
4208
+ this.alarmProcessor.start();
4209
+ debug("Alarm processor started", this.options.verbose);
4210
+ }
3564
4211
  success(`Emulator running at http://${this.options.host}:${String(this.options.port)}`);
3565
4212
  log(` Dashboard: http://${this.options.host}:${String(this.mockServer.port)}`);
3566
4213
  if (Object.keys(this.resolvedEnvVars).length > 0) {
@@ -3581,6 +4228,14 @@ var init_emulator = __esm({
3581
4228
  if (this.config.workflow) {
3582
4229
  log(` Workflow bindings: ${Object.keys(this.config.workflow).join(", ")}`);
3583
4230
  }
4231
+ if (this.config.cron) {
4232
+ for (const [name, expr] of Object.entries(this.config.cron)) {
4233
+ log(` Cron: ${name} = ${expr}`);
4234
+ }
4235
+ }
4236
+ if (this.config.alarm) {
4237
+ log(` Alarm bindings: ${Object.keys(this.config.alarm).join(", ")}`);
4238
+ }
3584
4239
  this.setupSignalHandlers();
3585
4240
  } catch (err) {
3586
4241
  error(`Failed to start emulator: ${err instanceof Error ? err.message : String(err)}`);
@@ -3705,6 +4360,14 @@ var init_emulator = __esm({
3705
4360
  process.on("SIGTERM", handler);
3706
4361
  }
3707
4362
  async stop() {
4363
+ if (this.cronScheduler) {
4364
+ this.cronScheduler.stop();
4365
+ this.cronScheduler = null;
4366
+ }
4367
+ if (this.alarmProcessor) {
4368
+ this.alarmProcessor.stop();
4369
+ this.alarmProcessor = null;
4370
+ }
3708
4371
  if (this.queueProcessor) {
3709
4372
  this.queueProcessor.stop();
3710
4373
  this.queueProcessor = null;
@@ -5600,6 +6263,24 @@ ${varProps}
5600
6263
  properties.push(` ${bindingName}: WorkflowBinding;`);
5601
6264
  }
5602
6265
  }
6266
+ if (config.fs) {
6267
+ const fsKeys = Object.keys(config.fs);
6268
+ if (fsKeys.length > 0) {
6269
+ imports.push("FileStorageBinding");
6270
+ for (const bindingName of fsKeys) {
6271
+ properties.push(` ${bindingName}: FileStorageBinding;`);
6272
+ }
6273
+ }
6274
+ }
6275
+ if (config.alarm) {
6276
+ const alarmKeys = Object.keys(config.alarm);
6277
+ if (alarmKeys.length > 0) {
6278
+ imports.push("AlarmBinding");
6279
+ for (const bindingName of alarmKeys) {
6280
+ properties.push(` ${bindingName}: AlarmBinding;`);
6281
+ }
6282
+ }
6283
+ }
5603
6284
  const lines = [
5604
6285
  "// This file is auto-generated by `ploy types`. Do not edit manually.",
5605
6286
  ""
@@ -5641,7 +6322,7 @@ async function typesCommand(options = {}) {
5641
6322
  console.error("Error: ploy.yaml not found in current directory");
5642
6323
  process.exit(1);
5643
6324
  }
5644
- const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow;
6325
+ const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow || config.fs || config.alarm || config.cron;
5645
6326
  if (!hasBindings2) {
5646
6327
  console.log("No bindings found in ploy.yaml. Generating empty Env.");
5647
6328
  }