@meetploy/cli 1.15.0 → 1.17.0

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
@@ -8,11 +8,11 @@ import { parse } from 'yaml';
8
8
  import { build } from 'esbuild';
9
9
  import { watch } from 'chokidar';
10
10
  import { randomBytes, randomUUID, createHmac, pbkdf2Sync, timingSafeEqual, createHash } from 'crypto';
11
+ import { homedir, tmpdir } from 'os';
11
12
  import { getCookie, deleteCookie, setCookie } from 'hono/cookie';
12
13
  import { URL, fileURLToPath } from 'url';
13
14
  import { serve } from '@hono/node-server';
14
15
  import { Hono } from 'hono';
15
- import { homedir, tmpdir } from 'os';
16
16
  import Database from 'better-sqlite3';
17
17
  import { spawn, exec } from 'child_process';
18
18
  import createClient from 'openapi-fetch';
@@ -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;
@@ -200,7 +216,9 @@ function validatePloyConfig(config, configFile = "ploy.yaml", options = {}) {
200
216
  validateBindings(config.queue, "queue", configFile);
201
217
  validateBindings(config.cache, "cache", configFile);
202
218
  validateBindings(config.state, "state", configFile);
219
+ validateBindings(config.fs, "fs", configFile);
203
220
  validateBindings(config.workflow, "workflow", configFile);
221
+ validateCronBindings(config.cron, configFile);
204
222
  if (config.ai !== void 0 && typeof config.ai !== "boolean") {
205
223
  throw new Error(`'ai' in ${configFile} must be a boolean`);
206
224
  }
@@ -276,7 +294,7 @@ function readAndValidatePloyConfigSync(projectDir, configPath, validationOptions
276
294
  return validatePloyConfig(config, configFile, validationOptions);
277
295
  }
278
296
  function hasBindings(config) {
279
- return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.workflow ?? config.ai ?? config.auth);
297
+ return !!(config.env ?? config.db ?? config.queue ?? config.cache ?? config.state ?? config.fs ?? config.workflow ?? config.cron ?? config.ai ?? config.auth);
280
298
  }
281
299
  function parseDotEnv(content) {
282
300
  const result = {};
@@ -338,7 +356,7 @@ function getWorkerEntryPoint(projectDir, config) {
338
356
  }
339
357
  throw new Error("Could not find worker entry point. Specify 'main' in ploy.yaml");
340
358
  }
341
- var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX;
359
+ var readFileAsync, DEFAULT_COMPATIBILITY_FLAGS, DEFAULT_COMPATIBILITY_DATE, BINDING_NAME_REGEX, RESOURCE_NAME_REGEX, CRON_EXPRESSION_REGEX;
342
360
  var init_ploy_config = __esm({
343
361
  "../tools/dist/ploy-config.js"() {
344
362
  readFileAsync = promisify(readFile$1);
@@ -346,6 +364,7 @@ var init_ploy_config = __esm({
346
364
  DEFAULT_COMPATIBILITY_DATE = "2025-04-02";
347
365
  BINDING_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
348
366
  RESOURCE_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
367
+ CRON_EXPRESSION_REGEX = /^(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)\s+(\*|[0-9,\-/]+)$/;
349
368
  }
350
369
  });
351
370
 
@@ -757,6 +776,124 @@ var init_db_runtime = __esm({
757
776
  }
758
777
  });
759
778
 
779
+ // ../emulator/dist/runtime/fs-runtime.js
780
+ var FS_RUNTIME_CODE;
781
+ var init_fs_runtime = __esm({
782
+ "../emulator/dist/runtime/fs-runtime.js"() {
783
+ FS_RUNTIME_CODE = `
784
+ interface FileStoragePutOptions {
785
+ contentType?: string;
786
+ }
787
+
788
+ interface FileStorageListOptions {
789
+ prefix?: string;
790
+ limit?: number;
791
+ }
792
+
793
+ interface FileStorageObject {
794
+ body: string;
795
+ contentType: string;
796
+ size: number;
797
+ }
798
+
799
+ interface FileStorageKey {
800
+ key: string;
801
+ size: number;
802
+ contentType: string;
803
+ }
804
+
805
+ interface FileStorageListResult {
806
+ keys: FileStorageKey[];
807
+ }
808
+
809
+ interface FileStorageBinding {
810
+ put: (key: string, value: string, options?: FileStoragePutOptions) => Promise<void>;
811
+ get: (key: string) => Promise<FileStorageObject | null>;
812
+ delete: (key: string) => Promise<void>;
813
+ list: (options?: FileStorageListOptions) => Promise<FileStorageListResult>;
814
+ }
815
+
816
+ export function initializeFileStorage(fsName: string, serviceUrl: string): FileStorageBinding {
817
+ return {
818
+ async put(key: string, value: string, options?: FileStoragePutOptions): Promise<void> {
819
+ const response = await fetch(serviceUrl + "/fs/put", {
820
+ method: "POST",
821
+ headers: { "Content-Type": "application/json" },
822
+ body: JSON.stringify({
823
+ fsName,
824
+ key,
825
+ value,
826
+ contentType: options?.contentType || "application/octet-stream",
827
+ }),
828
+ });
829
+
830
+ if (!response.ok) {
831
+ const errorText = await response.text();
832
+ throw new Error("File storage put failed: " + errorText);
833
+ }
834
+ },
835
+
836
+ async get(key: string): Promise<FileStorageObject | null> {
837
+ const response = await fetch(serviceUrl + "/fs/get", {
838
+ method: "POST",
839
+ headers: { "Content-Type": "application/json" },
840
+ body: JSON.stringify({ fsName, key }),
841
+ });
842
+
843
+ if (!response.ok) {
844
+ const errorText = await response.text();
845
+ throw new Error("File storage get failed: " + errorText);
846
+ }
847
+
848
+ const result = await response.json();
849
+ if (result.found === false) {
850
+ return null;
851
+ }
852
+ return {
853
+ body: result.body,
854
+ contentType: result.contentType,
855
+ size: result.size,
856
+ };
857
+ },
858
+
859
+ async delete(key: string): Promise<void> {
860
+ const response = await fetch(serviceUrl + "/fs/delete", {
861
+ method: "POST",
862
+ headers: { "Content-Type": "application/json" },
863
+ body: JSON.stringify({ fsName, key }),
864
+ });
865
+
866
+ if (!response.ok) {
867
+ const errorText = await response.text();
868
+ throw new Error("File storage delete failed: " + errorText);
869
+ }
870
+ },
871
+
872
+ async list(options?: FileStorageListOptions): Promise<FileStorageListResult> {
873
+ const response = await fetch(serviceUrl + "/fs/list", {
874
+ method: "POST",
875
+ headers: { "Content-Type": "application/json" },
876
+ body: JSON.stringify({
877
+ fsName,
878
+ prefix: options?.prefix,
879
+ limit: options?.limit,
880
+ }),
881
+ });
882
+
883
+ if (!response.ok) {
884
+ const errorText = await response.text();
885
+ throw new Error("File storage list failed: " + errorText);
886
+ }
887
+
888
+ const result = await response.json();
889
+ return { keys: result.keys };
890
+ },
891
+ };
892
+ }
893
+ `;
894
+ }
895
+ });
896
+
760
897
  // ../emulator/dist/runtime/queue-runtime.js
761
898
  var QUEUE_RUNTIME_CODE;
762
899
  var init_queue_runtime = __esm({
@@ -1121,6 +1258,12 @@ function generateWrapperCode(config, mockServiceUrl, envVars) {
1121
1258
  bindings.push(` ${bindingName}: initializeState("${stateName}", "${mockServiceUrl}"),`);
1122
1259
  }
1123
1260
  }
1261
+ if (config.fs) {
1262
+ imports.push('import { initializeFileStorage } from "__ploy_fs_runtime__";');
1263
+ for (const [bindingName, fsName] of Object.entries(config.fs)) {
1264
+ bindings.push(` ${bindingName}: initializeFileStorage("${fsName}", "${mockServiceUrl}"),`);
1265
+ }
1266
+ }
1124
1267
  if (config.workflow) {
1125
1268
  imports.push('import { initializeWorkflow, createStepContext, executeWorkflow } from "__ploy_workflow_runtime__";');
1126
1269
  for (const [bindingName, workflowName] of Object.entries(config.workflow)) {
@@ -1155,6 +1298,37 @@ function generateWrapperCode(config, mockServiceUrl, envVars) {
1155
1298
  }
1156
1299
  }
1157
1300
  }` : "";
1301
+ const cronHandlerCode = config.cron ? `
1302
+ // Handle cron trigger delivery
1303
+ if (request.headers.get("X-Ploy-Cron-Trigger") === "true") {
1304
+ const cronExpression = request.headers.get("X-Ploy-Cron-Expression");
1305
+ const scheduledTime = parseInt(request.headers.get("X-Ploy-Cron-Scheduled-Time") || String(Date.now()), 10);
1306
+
1307
+ if (cronExpression && userWorker.scheduled) {
1308
+ let noRetryFlag = false;
1309
+ const event = {
1310
+ cron: cronExpression,
1311
+ scheduledTime,
1312
+ noRetry() { noRetryFlag = true; }
1313
+ };
1314
+ try {
1315
+ await userWorker.scheduled(event, injectedEnv, ctx);
1316
+ return new Response(JSON.stringify({ success: true, noRetry: noRetryFlag }), {
1317
+ headers: { "Content-Type": "application/json" }
1318
+ });
1319
+ } catch (error) {
1320
+ return new Response(JSON.stringify({ success: false, error: String(error) }), {
1321
+ status: 500,
1322
+ headers: { "Content-Type": "application/json" }
1323
+ });
1324
+ }
1325
+ }
1326
+
1327
+ return new Response(JSON.stringify({ success: false, error: "No scheduled handler defined" }), {
1328
+ status: 404,
1329
+ headers: { "Content-Type": "application/json" }
1330
+ });
1331
+ }` : "";
1158
1332
  const queueHandlerCode = config.queue ? `
1159
1333
  // Handle queue message delivery
1160
1334
  if (request.headers.get("X-Ploy-Queue-Delivery") === "true") {
@@ -1198,6 +1372,7 @@ ${bindings.join("\n")}
1198
1372
  export default {
1199
1373
  async fetch(request, env, ctx) {
1200
1374
  const injectedEnv = { ...env, ...ployBindings };${envVarsCode}
1375
+ ${cronHandlerCode}
1201
1376
  ${workflowHandlerCode}
1202
1377
  ${queueHandlerCode}
1203
1378
 
@@ -1237,6 +1412,10 @@ function createRuntimePlugin(_config) {
1237
1412
  path: "__ploy_state_runtime__",
1238
1413
  namespace: "ploy-runtime"
1239
1414
  }));
1415
+ build2.onResolve({ filter: /^__ploy_fs_runtime__$/ }, () => ({
1416
+ path: "__ploy_fs_runtime__",
1417
+ namespace: "ploy-runtime"
1418
+ }));
1240
1419
  build2.onResolve({ filter: /^__ploy_workflow_runtime__$/ }, () => ({
1241
1420
  path: "__ploy_workflow_runtime__",
1242
1421
  namespace: "ploy-runtime"
@@ -1257,6 +1436,10 @@ function createRuntimePlugin(_config) {
1257
1436
  contents: STATE_RUNTIME_CODE,
1258
1437
  loader: "ts"
1259
1438
  }));
1439
+ build2.onLoad({ filter: /^__ploy_fs_runtime__$/, namespace: "ploy-runtime" }, () => ({
1440
+ contents: FS_RUNTIME_CODE,
1441
+ loader: "ts"
1442
+ }));
1260
1443
  build2.onLoad({ filter: /^__ploy_workflow_runtime__$/, namespace: "ploy-runtime" }, () => ({
1261
1444
  contents: WORKFLOW_RUNTIME_CODE,
1262
1445
  loader: "ts"
@@ -1299,6 +1482,7 @@ var init_bundler = __esm({
1299
1482
  "../emulator/dist/bundler/bundler.js"() {
1300
1483
  init_cache_runtime();
1301
1484
  init_db_runtime();
1485
+ init_fs_runtime();
1302
1486
  init_queue_runtime();
1303
1487
  init_state_runtime();
1304
1488
  init_workflow_runtime();
@@ -1559,6 +1743,205 @@ var init_workerd_config = __esm({
1559
1743
  "../emulator/dist/config/workerd-config.js"() {
1560
1744
  }
1561
1745
  });
1746
+ function parseCronField(field, min, max) {
1747
+ const values = /* @__PURE__ */ new Set();
1748
+ for (const part of field.split(",")) {
1749
+ const trimmed = part.trim();
1750
+ if (trimmed === "*") {
1751
+ for (let i = min; i <= max; i++) {
1752
+ values.add(i);
1753
+ }
1754
+ continue;
1755
+ }
1756
+ const stepMatch = trimmed.match(/^(.+)\/(\d+)$/);
1757
+ if (stepMatch) {
1758
+ const step = parseInt(stepMatch[2], 10);
1759
+ const base = stepMatch[1];
1760
+ let start = min;
1761
+ let end = max;
1762
+ if (base !== "*") {
1763
+ const rangeMatch2 = base.match(/^(\d+)-(\d+)$/);
1764
+ if (rangeMatch2) {
1765
+ start = parseInt(rangeMatch2[1], 10);
1766
+ end = parseInt(rangeMatch2[2], 10);
1767
+ } else {
1768
+ start = parseInt(base, 10);
1769
+ }
1770
+ }
1771
+ if (start < min || start > max || end < min || end > max) {
1772
+ throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
1773
+ }
1774
+ for (let i = start; i <= end; i += step) {
1775
+ values.add(i);
1776
+ }
1777
+ continue;
1778
+ }
1779
+ const rangeMatch = trimmed.match(/^(\d+)-(\d+)$/);
1780
+ if (rangeMatch) {
1781
+ const start = parseInt(rangeMatch[1], 10);
1782
+ const end = parseInt(rangeMatch[2], 10);
1783
+ if (start < min || start > max || end < min || end > max) {
1784
+ throw new Error(`Cron field range ${start}-${end} is out of bounds (${min}-${max})`);
1785
+ }
1786
+ for (let i = start; i <= end; i++) {
1787
+ values.add(i);
1788
+ }
1789
+ continue;
1790
+ }
1791
+ const num = parseInt(trimmed, 10);
1792
+ if (isNaN(num)) {
1793
+ throw new Error(`Invalid cron field value: '${trimmed}' (expected number, range, step, or wildcard)`);
1794
+ }
1795
+ if (num < min || num > max) {
1796
+ throw new Error(`Cron field value ${num} is out of range (${min}-${max})`);
1797
+ }
1798
+ values.add(num);
1799
+ }
1800
+ return { values };
1801
+ }
1802
+ function parseCronExpression(expression) {
1803
+ const parts = expression.trim().split(/\s+/);
1804
+ if (parts.length !== 5) {
1805
+ throw new Error(`Invalid cron expression: ${expression} (expected 5 fields)`);
1806
+ }
1807
+ return [
1808
+ parseCronField(parts[0], 0, 59),
1809
+ parseCronField(parts[1], 0, 23),
1810
+ parseCronField(parts[2], 1, 31),
1811
+ parseCronField(parts[3], 1, 12),
1812
+ parseCronField(parts[4], 0, 6)
1813
+ ];
1814
+ }
1815
+ function cronMatchesDate(fields, date) {
1816
+ const minute = date.getMinutes();
1817
+ const hour = date.getHours();
1818
+ const dayOfMonth = date.getDate();
1819
+ const month = date.getMonth() + 1;
1820
+ const dayOfWeek = date.getDay();
1821
+ 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);
1822
+ }
1823
+ function createCronScheduler(db, cronBindings, workerUrl) {
1824
+ let interval = null;
1825
+ const parsedCrons = /* @__PURE__ */ new Map();
1826
+ let lastCheckedMinute = -1;
1827
+ for (const [name, expression] of Object.entries(cronBindings)) {
1828
+ try {
1829
+ const fields = parseCronExpression(expression);
1830
+ parsedCrons.set(name, { expression, fields });
1831
+ } catch (err) {
1832
+ error(`Failed to parse cron expression for '${name}': ${err instanceof Error ? err.message : String(err)}`);
1833
+ }
1834
+ }
1835
+ async function checkCrons() {
1836
+ const now = /* @__PURE__ */ new Date();
1837
+ const currentMinute = Math.floor(now.getTime() / 6e4);
1838
+ if (currentMinute === lastCheckedMinute) {
1839
+ return;
1840
+ }
1841
+ lastCheckedMinute = currentMinute;
1842
+ for (const [name, cron] of parsedCrons) {
1843
+ if (cronMatchesDate(cron.fields, now)) {
1844
+ await triggerScheduledHandler(db, name, cron.expression, now, workerUrl);
1845
+ }
1846
+ }
1847
+ }
1848
+ return {
1849
+ start() {
1850
+ if (interval) {
1851
+ return;
1852
+ }
1853
+ log(`Cron scheduler started with ${parsedCrons.size} trigger(s)`);
1854
+ for (const [name, cron] of parsedCrons) {
1855
+ log(` ${name}: ${cron.expression}`);
1856
+ }
1857
+ interval = setInterval(() => {
1858
+ checkCrons().catch((err) => {
1859
+ error(`Cron check error: ${err instanceof Error ? err.message : String(err)}`);
1860
+ });
1861
+ }, 15e3);
1862
+ checkCrons().catch(() => {
1863
+ });
1864
+ },
1865
+ stop() {
1866
+ if (interval) {
1867
+ clearInterval(interval);
1868
+ interval = null;
1869
+ }
1870
+ }
1871
+ };
1872
+ }
1873
+ async function triggerScheduledHandler(db, cronName, cronExpression, triggerTime, workerUrl) {
1874
+ const executionId = randomUUID();
1875
+ const now = Math.floor(Date.now() / 1e3);
1876
+ db.prepare(`INSERT INTO cron_executions (id, cron_name, cron_expression, status, triggered_at)
1877
+ VALUES (?, ?, ?, 'running', ?)`).run(executionId, cronName, cronExpression, now);
1878
+ log(`[cron] Triggering '${cronName}' (${cronExpression}) execution=${executionId}`);
1879
+ try {
1880
+ const response = await fetch(workerUrl, {
1881
+ method: "POST",
1882
+ headers: {
1883
+ "Content-Type": "application/json",
1884
+ "X-Ploy-Cron-Trigger": "true",
1885
+ "X-Ploy-Cron-Name": cronName,
1886
+ "X-Ploy-Cron-Expression": cronExpression,
1887
+ "X-Ploy-Cron-Execution-Id": executionId,
1888
+ "X-Ploy-Cron-Scheduled-Time": String(triggerTime.getTime())
1889
+ },
1890
+ body: JSON.stringify({
1891
+ cron: cronExpression,
1892
+ scheduledTime: triggerTime.getTime()
1893
+ })
1894
+ });
1895
+ const completedAt = Math.floor(Date.now() / 1e3);
1896
+ const durationMs = Date.now() - triggerTime.getTime();
1897
+ if (response.ok) {
1898
+ db.prepare(`UPDATE cron_executions SET status = 'completed', completed_at = ?, duration_ms = ? WHERE id = ?`).run(completedAt, durationMs, executionId);
1899
+ log(`[cron] '${cronName}' completed successfully (${durationMs}ms)`);
1900
+ } else {
1901
+ const errorText = await response.text();
1902
+ db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, duration_ms = ?, error = ? WHERE id = ?`).run(completedAt, durationMs, errorText, executionId);
1903
+ error(`[cron] '${cronName}' failed: ${errorText}`);
1904
+ }
1905
+ } catch (err) {
1906
+ const completedAt = Math.floor(Date.now() / 1e3);
1907
+ const errorMessage = err instanceof Error ? err.message : String(err);
1908
+ db.prepare(`UPDATE cron_executions SET status = 'failed', completed_at = ?, error = ? WHERE id = ?`).run(completedAt, errorMessage, executionId);
1909
+ error(`[cron] '${cronName}' error: ${errorMessage}`);
1910
+ }
1911
+ }
1912
+ var init_cron_service = __esm({
1913
+ "../emulator/dist/services/cron-service.js"() {
1914
+ init_logger();
1915
+ }
1916
+ });
1917
+ function getProjectHash(projectDir) {
1918
+ return createHash("sha256").update(projectDir).digest("hex").slice(0, 12);
1919
+ }
1920
+ function getTempDir(projectDir) {
1921
+ const hash = getProjectHash(projectDir);
1922
+ return join(tmpdir(), `ploy-emulator-${hash}`);
1923
+ }
1924
+ function getDataDir(projectDir) {
1925
+ return join(projectDir, ".ploy");
1926
+ }
1927
+ function ensureDir(dir) {
1928
+ mkdirSync(dir, { recursive: true });
1929
+ }
1930
+ function ensureTempDir(projectDir) {
1931
+ const tempDir = getTempDir(projectDir);
1932
+ ensureDir(tempDir);
1933
+ return tempDir;
1934
+ }
1935
+ function ensureDataDir(projectDir) {
1936
+ const dataDir = getDataDir(projectDir);
1937
+ ensureDir(dataDir);
1938
+ ensureDir(join(dataDir, "db"));
1939
+ return dataDir;
1940
+ }
1941
+ var init_paths = __esm({
1942
+ "../emulator/dist/utils/paths.js"() {
1943
+ }
1944
+ });
1562
1945
  function generateId() {
1563
1946
  return randomBytes(16).toString("hex");
1564
1947
  }
@@ -1871,7 +2254,9 @@ function createDashboardRoutes(app, dbManager2, config) {
1871
2254
  queue: config.queue,
1872
2255
  cache: config.cache,
1873
2256
  state: config.state,
2257
+ fs: config.fs,
1874
2258
  workflow: config.workflow,
2259
+ cron: config.cron,
1875
2260
  auth: config.auth
1876
2261
  });
1877
2262
  });
@@ -2365,6 +2750,67 @@ function createDashboardRoutes(app, dbManager2, config) {
2365
2750
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2366
2751
  }
2367
2752
  });
2753
+ app.get("/api/fs/:binding/entries", (c) => {
2754
+ const binding = c.req.param("binding");
2755
+ const fsName = config.fs?.[binding];
2756
+ const limit = parseInt(c.req.query("limit") || "20", 10);
2757
+ const offset = parseInt(c.req.query("offset") || "0", 10);
2758
+ if (!fsName) {
2759
+ return c.json({ error: "File storage binding not found" }, 404);
2760
+ }
2761
+ try {
2762
+ const db = dbManager2.emulatorDb;
2763
+ const total = db.prepare(`SELECT COUNT(*) as count FROM fs_entries WHERE fs_name = ?`).get(fsName).count;
2764
+ const entries = db.prepare(`SELECT key, size, content_type, created_at
2765
+ FROM fs_entries
2766
+ WHERE fs_name = ?
2767
+ ORDER BY key ASC
2768
+ LIMIT ? OFFSET ?`).all(fsName, limit, offset);
2769
+ return c.json({
2770
+ entries: entries.map((e) => ({
2771
+ key: e.key,
2772
+ size: e.size,
2773
+ contentType: e.content_type,
2774
+ createdAt: new Date(e.created_at * 1e3).toISOString()
2775
+ })),
2776
+ total,
2777
+ limit,
2778
+ offset
2779
+ });
2780
+ } catch (err) {
2781
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2782
+ }
2783
+ });
2784
+ app.get("/api/cron/:binding/executions", (c) => {
2785
+ const binding = c.req.param("binding");
2786
+ const cronExpression = config.cron?.[binding];
2787
+ const limit = parseInt(c.req.query("limit") ?? "20", 10);
2788
+ if (!cronExpression) {
2789
+ return c.json({ error: "Cron binding not found" }, 404);
2790
+ }
2791
+ try {
2792
+ const db = dbManager2.emulatorDb;
2793
+ const executions = db.prepare(`SELECT id, cron_name, cron_expression, status, error, duration_ms, triggered_at, completed_at, created_at
2794
+ FROM cron_executions
2795
+ WHERE cron_name = ?
2796
+ ORDER BY triggered_at DESC
2797
+ LIMIT ?`).all(binding, limit);
2798
+ return c.json({
2799
+ cronExpression,
2800
+ executions: executions.map((e) => ({
2801
+ id: e.id,
2802
+ status: e.status.toUpperCase(),
2803
+ error: e.error,
2804
+ durationMs: e.duration_ms,
2805
+ triggeredAt: new Date(e.triggered_at * 1e3).toISOString(),
2806
+ completedAt: e.completed_at ? new Date(e.completed_at * 1e3).toISOString() : null,
2807
+ createdAt: new Date(e.created_at * 1e3).toISOString()
2808
+ }))
2809
+ });
2810
+ } catch (err) {
2811
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
2812
+ }
2813
+ });
2368
2814
  if (hasDashboard) {
2369
2815
  app.get("/assets/*", (c) => {
2370
2816
  const path = c.req.path;
@@ -2515,6 +2961,125 @@ var init_db_service = __esm({
2515
2961
  "../emulator/dist/services/db-service.js"() {
2516
2962
  }
2517
2963
  });
2964
+ function createFsHandlers(db, dataDir) {
2965
+ const fsBaseDir = join(dataDir, "fs");
2966
+ mkdirSync(fsBaseDir, { recursive: true });
2967
+ function getFsDir(fsName) {
2968
+ const dir = join(fsBaseDir, fsName);
2969
+ mkdirSync(dir, { recursive: true });
2970
+ return dir;
2971
+ }
2972
+ function encodedKeyPath(fsDir, key) {
2973
+ return join(fsDir, encodeURIComponent(key));
2974
+ }
2975
+ const putHandler = async (c) => {
2976
+ try {
2977
+ const body = await c.req.json();
2978
+ const { fsName, key, value, contentType } = body;
2979
+ if (!fsName || !key) {
2980
+ return c.json({ error: "Missing required fields: fsName, key, value" }, 400);
2981
+ }
2982
+ const fsDir = getFsDir(fsName);
2983
+ const filePath = encodedKeyPath(fsDir, key);
2984
+ writeFileSync(filePath, value, "utf-8");
2985
+ const size = Buffer.byteLength(value, "utf-8");
2986
+ const now = Math.floor(Date.now() / 1e3);
2987
+ db.prepare(`INSERT OR REPLACE INTO fs_entries (fs_name, key, content_type, size, created_at) VALUES (?, ?, ?, ?, ?)`).run(fsName, key, contentType ?? "application/octet-stream", size, now);
2988
+ return c.json({ success: true });
2989
+ } catch (err) {
2990
+ error(`[fs-service] put error: ${err instanceof Error ? err.message : String(err)}`);
2991
+ return c.json({
2992
+ error: `put failed: ${err instanceof Error ? err.message : String(err)}`
2993
+ }, 500);
2994
+ }
2995
+ };
2996
+ const getHandler = async (c) => {
2997
+ try {
2998
+ const body = await c.req.json();
2999
+ const { fsName, key } = body;
3000
+ if (!fsName || !key) {
3001
+ return c.json({ error: "Missing required fields: fsName, key" }, 400);
3002
+ }
3003
+ const row = db.prepare(`SELECT content_type, size FROM fs_entries WHERE fs_name = ? AND key = ?`).get(fsName, key);
3004
+ if (!row) {
3005
+ return c.json({ found: false });
3006
+ }
3007
+ const fsDir = getFsDir(fsName);
3008
+ const filePath = encodedKeyPath(fsDir, key);
3009
+ if (!existsSync(filePath)) {
3010
+ db.prepare(`DELETE FROM fs_entries WHERE fs_name = ? AND key = ?`).run(fsName, key);
3011
+ return c.json({ found: false });
3012
+ }
3013
+ const fileContent = readFileSync(filePath, "utf-8");
3014
+ return c.json({
3015
+ found: true,
3016
+ body: fileContent,
3017
+ contentType: row.content_type,
3018
+ size: row.size
3019
+ });
3020
+ } catch (err) {
3021
+ error(`[fs-service] get error: ${err instanceof Error ? err.message : String(err)}`);
3022
+ return c.json({
3023
+ error: `get failed: ${err instanceof Error ? err.message : String(err)}`
3024
+ }, 500);
3025
+ }
3026
+ };
3027
+ const deleteHandler = async (c) => {
3028
+ try {
3029
+ const body = await c.req.json();
3030
+ const { fsName, key } = body;
3031
+ if (!fsName || !key) {
3032
+ return c.json({ error: "Missing required fields: fsName, key" }, 400);
3033
+ }
3034
+ db.prepare(`DELETE FROM fs_entries WHERE fs_name = ? AND key = ?`).run(fsName, key);
3035
+ const fsDir = getFsDir(fsName);
3036
+ const filePath = encodedKeyPath(fsDir, key);
3037
+ if (existsSync(filePath)) {
3038
+ unlinkSync(filePath);
3039
+ }
3040
+ return c.json({ success: true });
3041
+ } catch (err) {
3042
+ error(`[fs-service] delete error: ${err instanceof Error ? err.message : String(err)}`);
3043
+ return c.json({
3044
+ error: `delete failed: ${err instanceof Error ? err.message : String(err)}`
3045
+ }, 500);
3046
+ }
3047
+ };
3048
+ const listHandler = async (c) => {
3049
+ try {
3050
+ const body = await c.req.json();
3051
+ const { fsName, prefix, limit } = body;
3052
+ if (!fsName) {
3053
+ return c.json({ error: "Missing required field: fsName" }, 400);
3054
+ }
3055
+ const effectiveLimit = limit ?? 1e3;
3056
+ const keys = prefix ? db.prepare(`SELECT key, size, content_type FROM fs_entries WHERE fs_name = ? AND key LIKE ? ORDER BY key ASC LIMIT ?`).all(fsName, `${prefix}%`, effectiveLimit) : db.prepare(`SELECT key, size, content_type FROM fs_entries WHERE fs_name = ? ORDER BY key ASC LIMIT ?`).all(fsName, effectiveLimit);
3057
+ return c.json({
3058
+ keys: keys.map((k) => ({
3059
+ key: k.key,
3060
+ size: k.size,
3061
+ contentType: k.content_type
3062
+ }))
3063
+ });
3064
+ } catch (err) {
3065
+ error(`[fs-service] list error: ${err instanceof Error ? err.message : String(err)}`);
3066
+ return c.json({
3067
+ error: `list failed: ${err instanceof Error ? err.message : String(err)}`
3068
+ }, 500);
3069
+ }
3070
+ };
3071
+ return {
3072
+ putHandler,
3073
+ getHandler,
3074
+ deleteHandler,
3075
+ listHandler
3076
+ };
3077
+ }
3078
+ var init_fs_service = __esm({
3079
+ "../emulator/dist/services/fs-service.js"() {
3080
+ init_logger();
3081
+ }
3082
+ });
2518
3083
  function createQueueHandlers(db) {
2519
3084
  const sendHandler = async (c) => {
2520
3085
  try {
@@ -2948,6 +3513,14 @@ async function startMockServer(dbManager2, config, options = {}) {
2948
3513
  app.post("/state/set", stateHandlers.setHandler);
2949
3514
  app.post("/state/delete", stateHandlers.deleteHandler);
2950
3515
  }
3516
+ if (config.fs) {
3517
+ const dataDir = getDataDir(process.cwd());
3518
+ const fsHandlers = createFsHandlers(dbManager2.emulatorDb, dataDir);
3519
+ app.post("/fs/put", fsHandlers.putHandler);
3520
+ app.post("/fs/get", fsHandlers.getHandler);
3521
+ app.post("/fs/delete", fsHandlers.deleteHandler);
3522
+ app.post("/fs/list", fsHandlers.listHandler);
3523
+ }
2951
3524
  if (config.auth) {
2952
3525
  const authHandlers = createAuthHandlers(dbManager2.emulatorDb);
2953
3526
  app.post("/auth/signup", authHandlers.signupHandler);
@@ -2979,44 +3552,18 @@ async function startMockServer(dbManager2, config, options = {}) {
2979
3552
  var DEFAULT_MOCK_SERVER_PORT;
2980
3553
  var init_mock_server = __esm({
2981
3554
  "../emulator/dist/services/mock-server.js"() {
3555
+ init_paths();
2982
3556
  init_auth_service();
2983
3557
  init_cache_service();
2984
3558
  init_dashboard_routes();
2985
3559
  init_db_service();
3560
+ init_fs_service();
2986
3561
  init_queue_service();
2987
3562
  init_state_service();
2988
3563
  init_workflow_service();
2989
3564
  DEFAULT_MOCK_SERVER_PORT = 4003;
2990
3565
  }
2991
3566
  });
2992
- function getProjectHash(projectDir) {
2993
- return createHash("sha256").update(projectDir).digest("hex").slice(0, 12);
2994
- }
2995
- function getTempDir(projectDir) {
2996
- const hash = getProjectHash(projectDir);
2997
- return join(tmpdir(), `ploy-emulator-${hash}`);
2998
- }
2999
- function getDataDir(projectDir) {
3000
- return join(projectDir, ".ploy");
3001
- }
3002
- function ensureDir(dir) {
3003
- mkdirSync(dir, { recursive: true });
3004
- }
3005
- function ensureTempDir(projectDir) {
3006
- const tempDir = getTempDir(projectDir);
3007
- ensureDir(tempDir);
3008
- return tempDir;
3009
- }
3010
- function ensureDataDir(projectDir) {
3011
- const dataDir = getDataDir(projectDir);
3012
- ensureDir(dataDir);
3013
- ensureDir(join(dataDir, "db"));
3014
- return dataDir;
3015
- }
3016
- var init_paths = __esm({
3017
- "../emulator/dist/utils/paths.js"() {
3018
- }
3019
- });
3020
3567
  function initializeDatabases(projectDir) {
3021
3568
  const dataDir = ensureDataDir(projectDir);
3022
3569
  const d1Databases = /* @__PURE__ */ new Map();
@@ -3158,6 +3705,32 @@ CREATE TABLE IF NOT EXISTS state_entries (
3158
3705
  value TEXT NOT NULL,
3159
3706
  PRIMARY KEY (state_name, key)
3160
3707
  );
3708
+
3709
+ -- File storage entries table (metadata for stored files)
3710
+ CREATE TABLE IF NOT EXISTS fs_entries (
3711
+ fs_name TEXT NOT NULL,
3712
+ key TEXT NOT NULL,
3713
+ content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
3714
+ size INTEGER NOT NULL DEFAULT 0,
3715
+ created_at INTEGER DEFAULT (strftime('%s', 'now')),
3716
+ PRIMARY KEY (fs_name, key)
3717
+ );
3718
+
3719
+ -- Cron executions table
3720
+ CREATE TABLE IF NOT EXISTS cron_executions (
3721
+ id TEXT PRIMARY KEY,
3722
+ cron_name TEXT NOT NULL,
3723
+ cron_expression TEXT NOT NULL,
3724
+ status TEXT DEFAULT 'running',
3725
+ error TEXT,
3726
+ duration_ms INTEGER,
3727
+ triggered_at INTEGER NOT NULL,
3728
+ completed_at INTEGER,
3729
+ created_at INTEGER DEFAULT (strftime('%s', 'now'))
3730
+ );
3731
+
3732
+ CREATE INDEX IF NOT EXISTS idx_cron_executions_name
3733
+ ON cron_executions(cron_name, triggered_at);
3161
3734
  `;
3162
3735
  }
3163
3736
  });
@@ -3174,6 +3747,7 @@ var init_emulator = __esm({
3174
3747
  init_env();
3175
3748
  init_ploy_config2();
3176
3749
  init_workerd_config();
3750
+ init_cron_service();
3177
3751
  init_mock_server();
3178
3752
  init_queue_service();
3179
3753
  init_logger();
@@ -3189,6 +3763,7 @@ var init_emulator = __esm({
3189
3763
  workerdProcess = null;
3190
3764
  fileWatcher = null;
3191
3765
  queueProcessor = null;
3766
+ cronScheduler = null;
3192
3767
  resolvedEnvVars = {};
3193
3768
  constructor(options = {}) {
3194
3769
  const port = options.port ?? 8787;
@@ -3256,6 +3831,12 @@ var init_emulator = __esm({
3256
3831
  this.queueProcessor.start();
3257
3832
  debug("Queue processor started", this.options.verbose);
3258
3833
  }
3834
+ if (this.config.cron) {
3835
+ const workerUrl2 = `http://${this.options.host}:${String(this.options.port)}`;
3836
+ this.cronScheduler = createCronScheduler(this.dbManager.emulatorDb, this.config.cron, workerUrl2);
3837
+ this.cronScheduler.start();
3838
+ debug("Cron scheduler started", this.options.verbose);
3839
+ }
3259
3840
  success(`Emulator running at http://${this.options.host}:${String(this.options.port)}`);
3260
3841
  log(` Dashboard: http://${this.options.host}:${String(this.mockServer.port)}`);
3261
3842
  if (Object.keys(this.resolvedEnvVars).length > 0) {
@@ -3270,9 +3851,17 @@ var init_emulator = __esm({
3270
3851
  if (this.config.cache) {
3271
3852
  log(` Cache bindings: ${Object.keys(this.config.cache).join(", ")}`);
3272
3853
  }
3854
+ if (this.config.fs) {
3855
+ log(` File Storage bindings: ${Object.keys(this.config.fs).join(", ")}`);
3856
+ }
3273
3857
  if (this.config.workflow) {
3274
3858
  log(` Workflow bindings: ${Object.keys(this.config.workflow).join(", ")}`);
3275
3859
  }
3860
+ if (this.config.cron) {
3861
+ for (const [name, expr] of Object.entries(this.config.cron)) {
3862
+ log(` Cron: ${name} = ${expr}`);
3863
+ }
3864
+ }
3276
3865
  this.setupSignalHandlers();
3277
3866
  } catch (err) {
3278
3867
  error(`Failed to start emulator: ${err instanceof Error ? err.message : String(err)}`);
@@ -3397,6 +3986,10 @@ var init_emulator = __esm({
3397
3986
  process.on("SIGTERM", handler);
3398
3987
  }
3399
3988
  async stop() {
3989
+ if (this.cronScheduler) {
3990
+ this.cronScheduler.stop();
3991
+ this.cronScheduler = null;
3992
+ }
3400
3993
  if (this.queueProcessor) {
3401
3994
  this.queueProcessor.stop();
3402
3995
  this.queueProcessor = null;
@@ -5333,7 +5926,7 @@ async function typesCommand(options = {}) {
5333
5926
  console.error("Error: ploy.yaml not found in current directory");
5334
5927
  process.exit(1);
5335
5928
  }
5336
- const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow;
5929
+ const hasBindings2 = config.env || config.ai || config.db || config.queue || config.cache || config.state || config.workflow || config.cron;
5337
5930
  if (!hasBindings2) {
5338
5931
  console.log("No bindings found in ploy.yaml. Generating empty Env.");
5339
5932
  }