diff --git a/apps/wing-console/console/server/src/router/app.ts b/apps/wing-console/console/server/src/router/app.ts index 032772351d5..b8e806d1f55 100644 --- a/apps/wing-console/console/server/src/router/app.ts +++ b/apps/wing-console/console/server/src/router/app.ts @@ -551,10 +551,7 @@ function createExplorerItemFromConstructTreeNode( showTests = false, includeHiddens = false, ): ExplorerItem { - const label = - node.display?.sourceModule === "@winglang/sdk" && node.display?.title - ? node.display?.title - : node.id; + const label = node.display?.title ?? node.id; return { id: node.path, diff --git a/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx b/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx index 22f4f150133..bfe0789b023 100644 --- a/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx +++ b/apps/wing-console/console/ui/src/ui/elk-map-nodes.tsx @@ -100,10 +100,7 @@ export const ContainerNode = memo( ); const compilerNamed = useMemo(() => { - if (!display) { - return false; - } - return display.sourceModule === "@winglang/sdk" && display.title; + return !!display?.title; }, [display]); return ( diff --git a/apps/wing-console/console/ui/src/ui/resource-metadata.tsx b/apps/wing-console/console/ui/src/ui/resource-metadata.tsx index b2ab592d0e4..1e23720d3d0 100644 --- a/apps/wing-console/console/ui/src/ui/resource-metadata.tsx +++ b/apps/wing-console/console/ui/src/ui/resource-metadata.tsx @@ -317,7 +317,9 @@ export const ResourceMetadata = memo(
-
{node.id}
+
+ {node?.display?.title ?? node.id} +
{node.type}
diff --git a/apps/wing/project-templates/wing/react-vite/backend/broadcaster.w b/apps/wing/project-templates/wing/react-vite/backend/broadcaster.w new file mode 100644 index 00000000000..794f1583d7b --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/backend/broadcaster.w @@ -0,0 +1,37 @@ +bring cloud; +bring websockets; +bring ui; + +pub class Broadcaster { + pub url: str; + server: websockets.WebSocket; + clients: cloud.Bucket; + + new() { + this.server = new websockets.WebSocket(name: "counter_updates") as "counter_updates"; + this.url = this.server.url; + this.clients = new cloud.Bucket(); + + // upon connection, add the client to the list + this.server.onConnect(inflight(id: str): void => { + this.clients.put(id, ""); + }); + + // upon disconnect, remove the client from the list + this.server.onDisconnect(inflight(id: str): void => { + this.clients.delete(id); + }); + + // Custom resource display in Console UI + // hide the websockets server and cloud bucket resources from the Console UI + nodeof(this.server).hidden = true; + nodeof(this.clients).hidden = true; + } + + // send a message to all clients + pub inflight broadcast(messgae: str) { + for id in this.clients.list() { + this.server.sendMessage(id, messgae); + } + } +} diff --git a/apps/wing/project-templates/wing/react-vite/backend/main.w b/apps/wing/project-templates/wing/react-vite/backend/main.w new file mode 100644 index 00000000000..2a86fb86de4 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/backend/main.w @@ -0,0 +1,47 @@ +bring cloud; +bring expect; +bring vite; +bring http; +bring "./broadcaster.w" as broadcaster; + +// Winglang doesn't have a built-in support for __dirname yet, so we use a workaround to get the current directory. +// @see tracking issue: https://github.com/winglang/wing/issues/3736 +class Utils { + extern "utils.js" pub static __dirname(): str; +} + +let myBroadcaster = new broadcaster.Broadcaster() as "Broadcaster"; +let api = new cloud.Api(cors: true); +let counter = new cloud.Counter(); + +let website = new vite.Vite( + root: "{Utils.__dirname()}/../frontend", + publicEnv: { + TITLE: "Wing + Vite + React", + API_URL: api.url, + WS_URL: myBroadcaster.url + } +) as "Vite Website"; + +api.get("/counter", inflight () => { + return { + body: "{counter.peek()}" + }; +}); + +api.post("/counter", inflight () => { + let prev = counter.inc(); + myBroadcaster.broadcast("refresh"); + return { + body: "{prev + 1}" + }; +}); + +test "api counter increment and get" { + log("counter initial value: {counter.peek()}"); + assert(counter.peek() == 0); + http.post(api.url + "/counter"); + let res = http.get(api.url + "/counter"); + log("counter value after increment: {res.body}"); + assert(res.body == "1"); +} diff --git a/apps/wing/project-templates/wing/react-vite/backend/package.json b/apps/wing/project-templates/wing/react-vite/backend/package.json new file mode 100644 index 00000000000..2274cd6cc62 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/backend/package.json @@ -0,0 +1,11 @@ +{ + "name": "my-wing-app", + "version": "0.0.0", + "description": "A description of my Wing application", + "author": "Your Name", + "license": "MIT", + "wing": true, + "dependencies": { + "@winglibs/vite": "^0.1.2" + } +} diff --git a/apps/wing/project-templates/wing/react-vite/backend/utils.js b/apps/wing/project-templates/wing/react-vite/backend/utils.js new file mode 100644 index 00000000000..e89ea1e8cd3 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/backend/utils.js @@ -0,0 +1 @@ +exports.__dirname = () => __dirname; diff --git a/apps/wing/project-templates/wing/react-vite/frontend/.eslintrc.cjs b/apps/wing/project-templates/wing/react-vite/frontend/.eslintrc.cjs new file mode 100644 index 00000000000..d6c95379530 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/.gitignore b/apps/wing/project-templates/wing/react-vite/frontend/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/wing/project-templates/wing/react-vite/frontend/README.md b/apps/wing/project-templates/wing/react-vite/frontend/README.md new file mode 100644 index 00000000000..0d6babeddbd --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/apps/wing/project-templates/wing/react-vite/frontend/index.html b/apps/wing/project-templates/wing/react-vite/frontend/index.html new file mode 100644 index 00000000000..e4b78eae123 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/apps/wing/project-templates/wing/react-vite/frontend/package.json b/apps/wing/project-templates/wing/react-vite/frontend/package.json new file mode 100644 index 00000000000..7ee69d28788 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-use-websocket": "^4.7.0" + }, + "devDependencies": { + "@types/react": "^18.2.56", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.0.2", + "@typescript-eslint/parser": "^7.0.2", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "typescript": "^5.2.2", + "vite": "^5.1.4" + } +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/public/vite.svg b/apps/wing/project-templates/wing/react-vite/frontend/public/vite.svg new file mode 100644 index 00000000000..e7b8dfb1b2a --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/App.css b/apps/wing/project-templates/wing/react-vite/frontend/src/App.css new file mode 100644 index 00000000000..b9d355df2a5 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/App.tsx b/apps/wing/project-templates/wing/react-vite/frontend/src/App.tsx new file mode 100644 index 00000000000..8d1132d6dcd --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/App.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react' +import reactLogo from './assets/react.svg' +import viteLogo from '/vite.svg' +import './App.css' +import useWebSocket from 'react-use-websocket'; + +const API_URL = window.wing.env.API_URL; +const WS_URL = window.wing.env.WS_URL; + +function App() { + const [count, setCount] = useState("0"); + const incrementCount = async () => { + const response = await fetch(`${API_URL}/counter`, { + method: "POST" + }); + setCount(await response.text()); + } + const updateCount = async () => { + const response = await fetch(`${API_URL}/counter`); + setCount(await response.text()); + } + + useWebSocket(WS_URL, { + onMessage: () => { + updateCount(); + } + }); + + useEffect(() => { + updateCount(); + }, []); + + return ( + <> +
+ + Vite logo + + + React logo + +
+

{window.wing.env.TITLE}

+
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and React logos to learn more +

+ + ) +} + +export default App; diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/assets/react.svg b/apps/wing/project-templates/wing/react-vite/frontend/src/assets/react.svg new file mode 100644 index 00000000000..6c87de9bb33 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/index.css b/apps/wing/project-templates/wing/react-vite/frontend/src/index.css new file mode 100644 index 00000000000..6119ad9a8fa --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/index.css @@ -0,0 +1,68 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/main.tsx b/apps/wing/project-templates/wing/react-vite/frontend/src/main.tsx new file mode 100644 index 00000000000..3d7150da80e --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/wing/project-templates/wing/react-vite/frontend/src/vite-env.d.ts b/apps/wing/project-templates/wing/react-vite/frontend/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.json b/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.json new file mode 100644 index 00000000000..a7fc6fbf23d --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.node.json b/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.node.json new file mode 100644 index 00000000000..97ede7ee6f2 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/wing/project-templates/wing/react-vite/frontend/vite.config.ts b/apps/wing/project-templates/wing/react-vite/frontend/vite.config.ts new file mode 100644 index 00000000000..5a33944a9b4 --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/apps/wing/project-templates/wing/react-vite/package.json b/apps/wing/project-templates/wing/react-vite/package.json new file mode 100644 index 00000000000..c3f0f57a50b --- /dev/null +++ b/apps/wing/project-templates/wing/react-vite/package.json @@ -0,0 +1,15 @@ +{ + "name": "my-app", + "version": "0.0.0", + "description": "A description of my application", + "author": "Your Name", + "license": "MIT", + "scripts": { + "install:backend": "cd backend && npm install", + "install:frontend": "cd frontend && npm install", + "postinstall": "npm run install:backend && npm run install:frontend" + }, + "dependencies": { + "@winglibs/websockets": "^0.3.2" + } +} diff --git a/docs/docs/04-standard-library/cloud/schedule.md b/docs/docs/04-standard-library/cloud/schedule.md index 766c745eb03..c10249df724 100644 --- a/docs/docs/04-standard-library/cloud/schedule.md +++ b/docs/docs/04-standard-library/cloud/schedule.md @@ -301,13 +301,21 @@ Trigger events according to a cron schedule using the UNIX cron format. Timezone is UTC. [minute] [hour] [day of month] [month] [day of week] +'*' means all possible values. +'-' means a range of values. +',' means a list of values. +[minute] allows 0-59. +[hour] allows 0-23. +[day of month] allows 1-31. +[month] allows 1-12 or JAN-DEC. +[day of week] allows 0-6 or SUN-SAT. --- *Example* ```wing -"0/1 * ? * *" +"* * * * *" ``` diff --git a/docs/docs/07-examples/10-using-javascript.md b/docs/docs/07-examples/10-using-javascript.md index 843ee116998..4b7b6640e63 100644 --- a/docs/docs/07-examples/10-using-javascript.md +++ b/docs/docs/07-examples/10-using-javascript.md @@ -1,56 +1,58 @@ --- -title: Using JavaScript +title: Using JavaScript/TypeScript id: using-javascript -keywords: [Wing example] +keywords: [example, javascript, extern, typescript, js, ts] --- -Calling a Javascript function from Wing requires two steps. +Calling a Javascript function from Wing requires two steps. -First, export the function from Javascript. - -This examples exports `isValidUrl` from a file named`url_utils.js`: +1. Create a .js file that exports some functions ```js -exports.isValidUrl = function(url) { - try { - new URL(url); - return true; - } catch { - return false; - } +// util.js + +exports.isValidUrl = function (url) { + return URL.canParse(url); }; ``` -### Preflight function - -To call this in preflight code, define the function as an `extern` in a class. - -**Note:** Extern functions must be `static.` +In preflight, this file must be a CommonJS module written in Javascript. Inflight, it may be CJS/ESM and either JavaScript or TypeScript. -If you want to use the function outside of the class, be sure to declare it as `pub`. +2. Use the `extern` keyword in a class to expose the function to Wing. Note that this must be `static`. It may also be `inflight` -```ts -class JsExample { - // preflight static - pub extern "./url_utils.js" static isValidUrl(url: str): bool; +```ts +class JsExample { + pub extern "./util.js" static isValidUrl(url: str): bool; } assert(JsExample.isValidUrl("http://www.google.com")); assert(!JsExample.isValidUrl("X?Y")); ``` -### Inflight +### Type-safe `extern` -To call the function inflight, add the `inflight` modifier. +Running `wing compile` will generate a corresponding `.d.ts` file for each `extern`. This file can be imported into the extern file itself to ensure the extern is type-safe. Either your IDE or a separate usage of the TypeScript compiler can provide type-checking. ```ts -class JsExample { - // inflight static method - extern "./url_utils.js" static inflight isValidUrl(url: str): bool; -} +// util.ts +import type extern from "./util.extern"; -test "main" { - assert(JsExample.isValidUrl("http://www.google.com")); - assert(!JsExample.isValidUrl("X?Y")); -} +export const isValidUrl: extern["isValidUrl"] = (url) => { + // url is known to be a string and that we must return a boolean + return URL.canParse(url); +}; ``` + +The .d.ts file can also be used in JavaScript via JSDoc comments and can even be applied at a module export level. + +```js +// util.js +/** @type {import("./util.extern").default} */ +module.exports = { + isValidUrl: (url) => { + return URL.canParse(url); + }, +}; +``` + +Coming Soon: The ability to use resources inside an `inflight extern`. See [this issue](https://github.com/winglang/wing/issues/76) for more information. diff --git a/examples/tests/sdk_tests/counter/initial.test.w b/examples/tests/sdk_tests/counter/initial.test.w index 0743217c625..3f4de8050f4 100644 --- a/examples/tests/sdk_tests/counter/initial.test.w +++ b/examples/tests/sdk_tests/counter/initial.test.w @@ -5,6 +5,10 @@ let counterA = new cloud.Counter() as "counterA"; let counterB = new cloud.Counter(initial: 500) as "counterB"; let counterC = new cloud.Counter(initial: -198) as "counterC"; +expect.equal(counterA.initial, 0); +expect.equal(counterB.initial, 500); +expect.equal(counterC.initial, -198); + test "initial:default" { expect.equal(counterA.peek(), 0); } diff --git a/examples/tests/sdk_tests/function/logging.extern.d.ts b/examples/tests/sdk_tests/function/logging.extern.d.ts new file mode 100644 index 00000000000..5bff5738bf1 --- /dev/null +++ b/examples/tests/sdk_tests/function/logging.extern.d.ts @@ -0,0 +1,3 @@ +export default interface extern { + logging: () => Promise, +} diff --git a/examples/tests/sdk_tests/schedule/init.test.w b/examples/tests/sdk_tests/schedule/init.test.w index e803edff0bb..dc8c3bd27a0 100644 --- a/examples/tests/sdk_tests/schedule/init.test.w +++ b/examples/tests/sdk_tests/schedule/init.test.w @@ -39,12 +39,4 @@ if (util.env("WING_TARGET") != "sim") { error = e; } assert(error == "cron string must be UNIX cron format [minute] [hour] [day of month] [month] [day of week]"); - - - try { - new cloud.Schedule( cron: "* * * * *" ) as "s5"; - } catch e { - error = e; - } - assert(error == "cannot use * in both the Day-of-month and Day-of-week fields. If you use it in one, you must use ? in the other"); } \ No newline at end of file diff --git a/examples/tests/sdk_tests/service/http-server.extern.d.ts b/examples/tests/sdk_tests/service/http-server.extern.d.ts new file mode 100644 index 00000000000..0c67ddf5937 --- /dev/null +++ b/examples/tests/sdk_tests/service/http-server.extern.d.ts @@ -0,0 +1,10 @@ +export default interface extern { + createServer: (body: string) => Promise, +} +export interface Address { + readonly port: number; +} +export interface IHttpServer$Inflight { + readonly address: () => Promise
; + readonly close: () => Promise; +} \ No newline at end of file diff --git a/examples/tests/sdk_tests/util/util.extern.d.ts b/examples/tests/sdk_tests/util/util.extern.d.ts new file mode 100644 index 00000000000..06d5b1d6059 --- /dev/null +++ b/examples/tests/sdk_tests/util/util.extern.d.ts @@ -0,0 +1,3 @@ +export default interface extern { + platform: () => Promise, +} diff --git a/examples/tests/sdk_tests/util/uuidv4-helper.extern.d.ts b/examples/tests/sdk_tests/util/uuidv4-helper.extern.d.ts new file mode 100644 index 00000000000..c92962f9d24 --- /dev/null +++ b/examples/tests/sdk_tests/util/uuidv4-helper.extern.d.ts @@ -0,0 +1,3 @@ +export default interface extern { + validateUUIDv4: (uuidv4: string) => Promise, +} diff --git a/examples/tests/sdk_tests/util/uuidv4.test.w b/examples/tests/sdk_tests/util/uuidv4.test.w index da958280ad6..9a4f3898dc1 100644 --- a/examples/tests/sdk_tests/util/uuidv4.test.w +++ b/examples/tests/sdk_tests/util/uuidv4.test.w @@ -1,11 +1,6 @@ bring util; -class JSHelper { - extern "./uuidv4-helper.js" pub static validateUUIDv4(uuidv4: str): bool; -} - -let data = util.uuidv4(); -assert(JSHelper.validateUUIDv4(data) == true); +let preflightData = util.uuidv4(); class JSHelperInflight { extern "./uuidv4-helper.js" pub static inflight validateUUIDv4(uuidv4: str): bool; @@ -14,4 +9,5 @@ class JSHelperInflight { test "inflight uuidv4" { let data = util.uuidv4(); assert(JSHelperInflight.validateUUIDv4(data) == true); + assert(JSHelperInflight.validateUUIDv4(preflightData) == true); } \ No newline at end of file diff --git a/examples/tests/valid/capture_tokens.test.w b/examples/tests/valid/capture_tokens.test.w index 5212f6e66d6..a0ec28807a8 100644 --- a/examples/tests/valid/capture_tokens.test.w +++ b/examples/tests/valid/capture_tokens.test.w @@ -4,7 +4,7 @@ class MyResource { api: cloud.Api; url: str; - extern "./url_utils.js" pub static inflight isValidUrl(url: str): bool; + extern "./url_utils.ts" pub static inflight isValidUrl(url: str): bool; new() { this.api = new cloud.Api(); diff --git a/examples/tests/valid/dynamo.extern.d.ts b/examples/tests/valid/dynamo.extern.d.ts new file mode 100644 index 00000000000..e0fda52f3c7 --- /dev/null +++ b/examples/tests/valid/dynamo.extern.d.ts @@ -0,0 +1,4 @@ +export default interface extern { + _getItem: (tableName: string, key: Readonly) => Promise>, + _putItem: (tableName: string, item: Readonly) => Promise, +} diff --git a/examples/tests/valid/dynamo.test.w b/examples/tests/valid/dynamo.test.w index c95d3276f75..d1a8817b6d8 100644 --- a/examples/tests/valid/dynamo.test.w +++ b/examples/tests/valid/dynamo.test.w @@ -54,7 +54,8 @@ class DynamoTable { } } - extern "./dynamo.js" static inflight _putItem(tableName: str, item: Json): void; + extern "./dynamo.ts" static inflight _getItem(tableName: str, key: Json): Json; + extern "./dynamo.ts" static inflight _putItem(tableName: str, item: Json): void; pub inflight putItem(item: Map) { let json = this._itemToJson(item); diff --git a/examples/tests/valid/dynamo.js b/examples/tests/valid/dynamo.ts similarity index 55% rename from examples/tests/valid/dynamo.js rename to examples/tests/valid/dynamo.ts index 04c09c8c2c1..9517414f156 100644 --- a/examples/tests/valid/dynamo.js +++ b/examples/tests/valid/dynamo.ts @@ -1,8 +1,13 @@ -const { DynamoDBClient, PutItemCommand, GetItemCommand } = require("@aws-sdk/client-dynamodb"); +import type extern from "./dynamo.extern"; +import { + DynamoDBClient, + PutItemCommand, + GetItemCommand, +} from "@aws-sdk/client-dynamodb"; const client = new DynamoDBClient({}); -export async function _putItem(tableName, item) { +export const _putItem: extern["_putItem"] = async (tableName, item) => { const command = new PutItemCommand({ TableName: tableName, Item: item, @@ -11,15 +16,15 @@ export async function _putItem(tableName, item) { const response = await client.send(command); console.log(response); return; -} +}; -export async function _getItem(tableName, key) { +export const _getItem: extern["_getItem"] = async (tableName, key) => { const command = new GetItemCommand({ TableName: tableName, - Key: key + Key: key, }); const response = await client.send(command); console.log(response); return response; -} \ No newline at end of file +}; diff --git a/examples/tests/valid/dynamo_awscdk.test.w b/examples/tests/valid/dynamo_awscdk.test.w index 3e916121efc..ad1682860ea 100644 --- a/examples/tests/valid/dynamo_awscdk.test.w +++ b/examples/tests/valid/dynamo_awscdk.test.w @@ -60,13 +60,13 @@ class DynamoTable { } } - extern "./dynamo.js" static inflight _putItem(tableName: str, item: Json): void; + extern "./dynamo.ts" static inflight _putItem(tableName: str, item: Json): void; pub inflight putItem(item: Map) { let json = this._itemToJson(item); DynamoTable._putItem(this.tableName, json); } - extern "./dynamo.js" static inflight _getItem(tableName: str, key: Json): Json; + extern "./dynamo.ts" static inflight _getItem(tableName: str, key: Json): Json; pub inflight getItem(key: Map): Json { let json = this._itemToJson(key); return DynamoTable._getItem(this.tableName, json); diff --git a/examples/tests/valid/extern_implementation.test.w b/examples/tests/valid/extern_implementation.test.w index 98425d6b80b..10d7bdfa8a3 100644 --- a/examples/tests/valid/extern_implementation.test.w +++ b/examples/tests/valid/extern_implementation.test.w @@ -6,6 +6,7 @@ class Foo { extern "./external_js.js" static inflight getUuid(): str; extern "./external_js.js" static inflight getData(): str; extern "./external_js.js" pub static inflight print(msg: str): void; + extern "./external_js.js" pub static preflightBucket(bucket: cloud.Bucket, id: str): Json; pub inflight call() { assert(Foo.regexInflight("[a-z]+-\\d+", "abc-123")); @@ -20,6 +21,9 @@ assert(Foo.getGreeting("Wingding") == "Hello, Wingding!"); let f = new Foo(); +let bucket = new cloud.Bucket() as "my-bucket"; +let result = Foo.preflightBucket(bucket, "my-bucket"); + test "call" { f.call(); } diff --git a/examples/tests/valid/external_js.extern.d.ts b/examples/tests/valid/external_js.extern.d.ts new file mode 100644 index 00000000000..079bebe0c07 --- /dev/null +++ b/examples/tests/valid/external_js.extern.d.ts @@ -0,0 +1,244 @@ +export default interface extern { + getData: () => Promise, + getGreeting: (name: string) => string, + getUuid: () => Promise, + preflightBucket: (bucket: Bucket, id: string) => Readonly, + print: (msg: string) => Promise, + regexInflight: (pattern: string, text: string) => Promise, +} +/** Trait marker for classes that can be depended upon. +The presence of this interface indicates that an object has +an `IDependable` implementation. + +This interface can be used to take an (ordering) dependency on a set of +constructs. An ordering dependency implies that the resources represented by +those constructs are deployed before the resources depending ON them are +deployed. */ +export interface IDependable { +} +/** Options for `construct.addMetadata()`. */ +export interface MetadataOptions { + /** Include stack trace with metadata entry. */ + readonly stackTrace?: (boolean) | undefined; + /** A JavaScript function to begin tracing from. + This option is ignored unless `stackTrace` is `true`. */ + readonly traceFromFunction?: (any) | undefined; +} +/** Implement this interface in order for the construct to be able to validate itself. */ +export interface IValidation { + /** Validate the current construct. + This method can be implemented by derived constructs in order to perform + validation logic. It is called on all constructs before synthesis. + @returns An array of validation error messages, or an empty array if there the construct is valid. */ + readonly validate: () => (readonly (string)[]); +} +/** In what order to return constructs. */ +export enum ConstructOrder { + PREORDER = 0, + POSTORDER = 1, +} +/** An entry in the construct metadata table. */ +export interface MetadataEntry { + /** The data. */ + readonly data?: any; + /** Stack trace at the point of adding the metadata. + Only available if `addMetadata()` is called with `stackTrace: true`. */ + readonly trace?: ((readonly (string)[])) | undefined; + /** The metadata entry type. */ + readonly type: string; +} +/** Represents the construct node in the scope tree. */ +export class Node { + /** Add an ordering dependency on another construct. + An `IDependable` */ + readonly addDependency: (deps?: ((readonly (IDependable)[])) | undefined) => void; + /** Adds a metadata entry to this construct. + Entries are arbitrary values and will also include a stack trace to allow tracing back to + the code location for when the entry was added. It can be used, for example, to include source + mapping in CloudFormation templates to improve diagnostics. */ + readonly addMetadata: (type: string, data?: any, options?: (MetadataOptions) | undefined) => void; + /** Adds a validation to this construct. + When `node.validate()` is called, the `validate()` method will be called on + all validations and all errors will be returned. */ + readonly addValidation: (validation: IValidation) => void; + /** Returns an opaque tree-unique address for this construct. + Addresses are 42 characters hexadecimal strings. They begin with "c8" + followed by 40 lowercase hexadecimal characters (0-9a-f). + + Addresses are calculated using a SHA-1 of the components of the construct + path. + + To enable refactorings of construct trees, constructs with the ID `Default` + will be excluded from the calculation. In those cases constructs in the + same tree may have the same addreess. + c83a2846e506bcc5f10682b564084bca2d275709ee */ + readonly addr: string; + /** All direct children of this construct. */ + readonly children: (readonly (IConstruct)[]); + /** Returns the child construct that has the id `Default` or `Resource"`. + This is usually the construct that provides the bulk of the underlying functionality. + Useful for modifications of the underlying construct that are not available at the higher levels. + Override the defaultChild property. + + This should only be used in the cases where the correct + default child is not named 'Resource' or 'Default' as it + should be. + + If you set this to undefined, the default behavior of finding + the child named 'Resource' or 'Default' will be used. + @returns a construct or undefined if there is no default child */ + defaultChild?: (IConstruct) | undefined; + /** Return all dependencies registered on this node (non-recursive). */ + readonly dependencies: (readonly (IConstruct)[]); + /** Return this construct and all of its children in the given order. */ + readonly findAll: (order?: (ConstructOrder) | undefined) => (readonly (IConstruct)[]); + /** Return a direct child by id. + Throws an error if the child is not found. + @returns Child with the given id. */ + readonly findChild: (id: string) => IConstruct; + /** Retrieves the all context of a node from tree context. + Context is usually initialized at the root, but can be overridden at any point in the tree. + @returns The context object or an empty object if there is discovered context */ + readonly getAllContext: (defaults?: (Readonly) | undefined) => any; + /** Retrieves a value from tree context if present. Otherwise, would throw an error. + Context is usually initialized at the root, but can be overridden at any point in the tree. + @returns The context value or throws error if there is no context value for this key */ + readonly getContext: (key: string) => any; + /** The id of this construct within the current scope. + This is a scope-unique id. To obtain an app-unique id for this construct, use `addr`. */ + readonly id: string; + /** Locks this construct from allowing more children to be added. + After this + call, no more children can be added to this construct or to any children. */ + readonly lock: () => void; + /** Returns true if this construct or the scopes in which it is defined are locked. */ + readonly locked: boolean; + /** An immutable array of metadata objects associated with this construct. + This can be used, for example, to implement support for deprecation notices, source mapping, etc. */ + readonly metadata: (readonly (MetadataEntry)[]); + /** The full, absolute path of this construct in the tree. + Components are separated by '/'. */ + readonly path: string; + /** Returns the root of the construct tree. + @returns The root of the construct tree. */ + readonly root: IConstruct; + /** Returns the scope in which this construct is defined. + The value is `undefined` at the root of the construct scope tree. */ + readonly scope?: (IConstruct) | undefined; + /** All parent scopes of this construct. + @returns a list of parent scopes. The last element in the list will always + be the current construct and the first element will be the root of the + tree. */ + readonly scopes: (readonly (IConstruct)[]); + /** This can be used to set contextual values. + Context must be set before any children are added, since children may consult context info during construction. + If the key already exists, it will be overridden. */ + readonly setContext: (key: string, value?: any) => void; + /** Return a direct child by id, or undefined. + @returns the child if found, or undefined */ + readonly tryFindChild: (id: string) => (IConstruct) | undefined; + /** Retrieves a value from tree context. + Context is usually initialized at the root, but can be overridden at any point in the tree. + @returns The context value or `undefined` if there is no context value for this key. */ + readonly tryGetContext: (key: string) => any; + /** Remove the child with the given name, if present. + @returns Whether a child with the given name was deleted. */ + readonly tryRemoveChild: (childName: string) => boolean; + /** Validates this construct. + Invokes the `validate()` method on all validations added through + `addValidation()`. + @returns an array of validation error messages associated with this + construct. */ + readonly validate: () => (readonly (string)[]); +} +/** Represents a construct. */ +export interface IConstruct extends IDependable { + /** The tree node. */ + readonly node: Node; +} +/** Represents the building block of the construct graph. +All constructs besides the root construct must be created within the scope of +another construct. */ +export class Construct implements IConstruct { + /** The tree node. */ + readonly node: Node; + /** Returns a string representation of this construct. */ + readonly toString: () => string; +} +/** Data that can be lifted into inflight. */ +export interface ILiftable { +} +/** A resource that can run inflight code. */ +export interface IInflightHost extends IResource { + /** Adds an environment variable to the host. */ + readonly addEnvironment: (name: string, value: string) => void; +} +/** A liftable object that needs to be registered on the host as part of the lifting process. +This is generally used so the host can set up permissions +to access the lifted object inflight. */ +export interface IHostedLiftable extends ILiftable { + /** A hook called by the Wing compiler once for each inflight host that needs to use this object inflight. + The list of requested inflight methods + needed by the inflight host are given by `ops`. + + This method is commonly used for adding permissions, environment variables, or + other capabilities to the inflight host. */ + readonly onLift: (host: IInflightHost, ops: (readonly (string)[])) => void; +} +/** Abstract interface for `Resource`. */ +export interface IResource extends IConstruct, IHostedLiftable { + readonly node: Node; + readonly onLift: (host: IInflightHost, ops: (readonly (string)[])) => void; +} +/** Shared behavior between all Wing SDK resources. */ +export class Resource extends Construct implements IResource { + /** A hook called by the Wing compiler once for each inflight host that needs to use this resource inflight. + You can override this method to perform additional logic like granting + IAM permissions to the host based on what methods are being called. But + you must call `super.bind(host, ops)` to ensure that the resource is + actually bound. */ + readonly onLift: (host: IInflightHost, ops: (readonly (string)[])) => void; +} +/** Code that runs at runtime and implements your application's behavior. +For example, handling API requests, processing queue messages, etc. +Inflight code can be executed on various compute platforms in the cloud, +such as function services (such as AWS Lambda or Azure Functions), +containers (such as ECS or Kubernetes), VMs or even physical servers. + +This data represents the code together with the bindings to preflight data required to run. */ +export interface IInflight extends IHostedLiftable { + readonly onLift: (host: IInflightHost, ops: (readonly (string)[])) => void; +} +/** A resource with an inflight "handle" method that can be passed to the bucket events. */ +export interface IBucketEventHandler extends IInflight { + readonly onLift: (host: IInflightHost, ops: (readonly (string)[])) => void; +} +/** `onCreate` event options. */ +export interface BucketOnCreateOptions { +} +/** `onDelete` event options. */ +export interface BucketOnDeleteOptions { +} +/** `onEvent` options. */ +export interface BucketOnEventOptions { +} +/** `onUpdate` event options. */ +export interface BucketOnUpdateOptions { +} +/** A cloud object store. */ +export class Bucket extends Resource { + /** Add a file to the bucket from system folder. */ + readonly addFile: (key: string, path: string, encoding?: (string) | undefined) => void; + /** Add a file to the bucket that is uploaded when the app is deployed. + TODO: In the future this will support uploading any `Blob` type or + referencing a file from the local filesystem. */ + readonly addObject: (key: string, body: string) => void; + /** Run an inflight whenever a file is uploaded to the bucket. */ + readonly onCreate: (fn: IBucketEventHandler, opts?: (BucketOnCreateOptions) | undefined) => void; + /** Run an inflight whenever a file is deleted from the bucket. */ + readonly onDelete: (fn: IBucketEventHandler, opts?: (BucketOnDeleteOptions) | undefined) => void; + /** Run an inflight whenever a file is uploaded, modified, or deleted from the bucket. */ + readonly onEvent: (fn: IBucketEventHandler, opts?: (BucketOnEventOptions) | undefined) => void; + /** Run an inflight whenever a file is updated in the bucket. */ + readonly onUpdate: (fn: IBucketEventHandler, opts?: (BucketOnUpdateOptions) | undefined) => void; +} \ No newline at end of file diff --git a/examples/tests/valid/external_js.js b/examples/tests/valid/external_js.js index dbfdd7651f8..2c054a63d9c 100644 --- a/examples/tests/valid/external_js.js +++ b/examples/tests/valid/external_js.js @@ -1,21 +1,25 @@ -exports.getGreeting = function(name) { - return `Hello, ${name}!`; -}; - -exports.regexInflight = async function(pattern, text) { - const regex = new RegExp(pattern); - return regex.test(text); -}; - -exports.getUuid = async function() { - let uuid = require("uuid"); - return uuid.v4(); -}; - -exports.getData = async function() { - return require("./exported_data.js"); -}; +const assert = require("node:assert"); -exports.print = function(msg) { - console.log(`printing ${msg}`); +/** @type {import("./external_js.extern").default} */ +module.exports = { + getGreeting(name) { + return `Hello, ${name}!`; + }, + preflightBucket(bucket, id) { + assert.strictEqual(bucket.node.id, id); + }, + async regexInflight(pattern, text) { + const regex = new RegExp(pattern); + return regex.test(text); + }, + async getUuid() { + let uuid = require("uuid"); + return uuid.v4(); + }, + async getData() { + return require("./exported_data.js"); + }, + async print(msg) { + console.log(`printing ${msg}`); + }, }; diff --git a/examples/tests/valid/subdir/subfile.w b/examples/tests/valid/subdir/subfile.w index 7858188260d..0981074aee2 100644 --- a/examples/tests/valid/subdir/subfile.w +++ b/examples/tests/valid/subdir/subfile.w @@ -1,5 +1,5 @@ bring math; pub class Q { - extern "./util.js" static inflight greet(name: str): str; + extern "./util.ts" static inflight greet(name: str): str; } diff --git a/examples/tests/valid/subdir/util.extern.d.ts b/examples/tests/valid/subdir/util.extern.d.ts new file mode 100644 index 00000000000..d941b3480e7 --- /dev/null +++ b/examples/tests/valid/subdir/util.extern.d.ts @@ -0,0 +1,3 @@ +export default interface extern { + greet: (name: string) => Promise, +} diff --git a/examples/tests/valid/subdir/util.js b/examples/tests/valid/subdir/util.js deleted file mode 100644 index 9613a35374a..00000000000 --- a/examples/tests/valid/subdir/util.js +++ /dev/null @@ -1,3 +0,0 @@ -exports.greet = function(name) { - return 'Hello ' + name; -} diff --git a/examples/tests/valid/subdir/util.ts b/examples/tests/valid/subdir/util.ts new file mode 100644 index 00000000000..833d6b144c2 --- /dev/null +++ b/examples/tests/valid/subdir/util.ts @@ -0,0 +1,5 @@ +import type extern from "./util.extern"; + +export const greet: extern["greet"] = async (name) => { + return "Hello " + name; +} diff --git a/examples/tests/valid/url_utils.extern.d.ts b/examples/tests/valid/url_utils.extern.d.ts new file mode 100644 index 00000000000..d44c96f5206 --- /dev/null +++ b/examples/tests/valid/url_utils.extern.d.ts @@ -0,0 +1,3 @@ +export default interface extern { + isValidUrl: (url: string) => Promise, +} diff --git a/examples/tests/valid/url_utils.js b/examples/tests/valid/url_utils.js deleted file mode 100644 index 77bc7a93eef..00000000000 --- a/examples/tests/valid/url_utils.js +++ /dev/null @@ -1,8 +0,0 @@ -exports.isValidUrl = function(url) { - try { - new URL(url); - return true; - } catch { - return false; - } -}; \ No newline at end of file diff --git a/examples/tests/valid/url_utils.ts b/examples/tests/valid/url_utils.ts new file mode 100644 index 00000000000..5354d5835aa --- /dev/null +++ b/examples/tests/valid/url_utils.ts @@ -0,0 +1,5 @@ +import type extern from "./url_utils.extern"; + +export const isValidUrl: extern["isValidUrl"] = async (url) => { + return URL.canParse(url); +} \ No newline at end of file diff --git a/examples/wing-fixture/util.extern.d.ts b/examples/wing-fixture/util.extern.d.ts new file mode 100644 index 00000000000..1ff1975996d --- /dev/null +++ b/examples/wing-fixture/util.extern.d.ts @@ -0,0 +1,4 @@ +export default interface extern { + makeKey: (name: string) => string, + makeKeyInflight: (name: string) => Promise, +} diff --git a/libs/awscdk/src/schedule.ts b/libs/awscdk/src/schedule.ts index 6bdb8471ccb..b30978181d0 100644 --- a/libs/awscdk/src/schedule.ts +++ b/libs/awscdk/src/schedule.ts @@ -9,8 +9,10 @@ import { Construct } from "constructs"; import { App } from "./app"; import { cloud, core, std } from "@winglang/sdk"; import { convertBetweenHandlers } from "@winglang/sdk/lib/shared/convert"; +import { convertUnixCronToAWSCron } from "@winglang/sdk/lib/shared-aws/schedule"; import { isAwsCdkFunction } from "./function"; + /** * AWS implementation of `cloud.Schedule`. * @@ -25,27 +27,15 @@ export class Schedule extends cloud.Schedule { const { rate, cron } = props; - /* - * The schedule cron string is Unix cron format: [minute] [hour] [day of month] [month] [day of week] - * AWS EventBridge Schedule uses a 6 field format which includes year: [minute] [hour] [day of month] [month] [day of week] [year] - * https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based - * - * We append * to the cron string for year field. - */ if (cron) { - const cronArr = cron.split(" "); - let cronOpt: { [k: string]: string } = { - minute: cronArr[0], - hour: cronArr[1], - month: cronArr[3], - year: "*", - }; - if (cronArr[2] !== "?") { - cronOpt.day = cronArr[2]; - } - if (cronArr[4] !== "?") { - cronOpt.weekDay = cronArr[4]; - } + let cronOpt: { [k: string]: string } = {}; + const awsCron = convertUnixCronToAWSCron(cron); + const cronArr = awsCron.split(" "); + if (cronArr[0] !== "*" && cronArr[0] !== "?") { cronOpt.minute = cronArr[0]; } + if (cronArr[1] !== "*" && cronArr[1] !== "?") { cronOpt.hour = cronArr[1]; } + if (cronArr[2] !== "*" && cronArr[2] !== "?") { cronOpt.day = cronArr[2]; } + if (cronArr[3] !== "*" && cronArr[3] !== "?") { cronOpt.month = cronArr[3]; } + if (cronArr[4] !== "*" && cronArr[4] !== "?") { cronOpt.weekDay = cronArr[4]; } this.scheduleExpression = EventSchedule.cron(cronOpt); } else { diff --git a/libs/awscdk/test/__snapshots__/schedule.test.ts.snap b/libs/awscdk/test/__snapshots__/schedule.test.ts.snap index 535a6820c2d..63bb0e132ba 100644 --- a/libs/awscdk/test/__snapshots__/schedule.test.ts.snap +++ b/libs/awscdk/test/__snapshots__/schedule.test.ts.snap @@ -1,5 +1,467 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`convert single dayOfWeek from Unix to AWS 1`] = ` +{ + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "cron(* * ? * 0 *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleOnTick0FF908B3EE22FC7ED": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleOnTick059D62C99": { + "DependsOn": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "", + }, + "Environment": { + "Variables": { + "NODE_OPTIONS": "--enable-source-maps", + }, + }, + "Handler": "index.handler", + "LoggingConfig": { + "LogGroup": { + "Ref": "ScheduleOnTick0LogGroup389684B1", + }, + }, + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 60, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleOnTick0LogGroup389684B1": { + "DeletionPolicy": "Retain", + "Properties": { + "RetentionInDays": 30, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "ScheduleOnTick0ServiceRole37EF1AE1": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; + +exports[`convert the list of dayOfWeek from Unix to AWS 1`] = ` +{ + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "cron(* * ? * 0,2,4,6 *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleOnTick0FF908B3EE22FC7ED": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleOnTick059D62C99": { + "DependsOn": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "", + }, + "Environment": { + "Variables": { + "NODE_OPTIONS": "--enable-source-maps", + }, + }, + "Handler": "index.handler", + "LoggingConfig": { + "LogGroup": { + "Ref": "ScheduleOnTick0LogGroup389684B1", + }, + }, + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 60, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleOnTick0LogGroup389684B1": { + "DeletionPolicy": "Retain", + "Properties": { + "RetentionInDays": 30, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "ScheduleOnTick0ServiceRole37EF1AE1": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; + +exports[`convert the range of dayOfWeek from Unix to AWS 1`] = ` +{ + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "cron(* * ? * 0-6 *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleOnTick0FF908B3EE22FC7ED": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleOnTick059D62C99", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleOnTick059D62C99": { + "DependsOn": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + ], + "Properties": { + "Architectures": [ + "arm64", + ], + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "", + }, + "Environment": { + "Variables": { + "NODE_OPTIONS": "--enable-source-maps", + }, + }, + "Handler": "index.handler", + "LoggingConfig": { + "LogGroup": { + "Ref": "ScheduleOnTick0LogGroup389684B1", + }, + }, + "MemorySize": 1024, + "Role": { + "Fn::GetAtt": [ + "ScheduleOnTick0ServiceRole37EF1AE1", + "Arn", + ], + }, + "Runtime": "nodejs20.x", + "Timeout": 60, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleOnTick0LogGroup389684B1": { + "DeletionPolicy": "Retain", + "Properties": { + "RetentionInDays": 30, + }, + "Type": "AWS::Logs::LogGroup", + "UpdateReplacePolicy": "Retain", + }, + "ScheduleOnTick0ServiceRole37EF1AE1": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; + exports[`schedule behavior with cron 1`] = ` { "Parameters": { @@ -12,7 +474,7 @@ exports[`schedule behavior with cron 1`] = ` "Resources": { "Schedule251B1F83": { "Properties": { - "ScheduleExpression": "cron(0/1 * ? * * *)", + "ScheduleExpression": "cron(0/1 * * * ? *)", "State": "ENABLED", "Targets": [ { @@ -320,7 +782,7 @@ exports[`schedule with two functions 1`] = ` "Resources": { "Schedule251B1F83": { "Properties": { - "ScheduleExpression": "cron(0/1 * ? * * *)", + "ScheduleExpression": "cron(0/1 * * * ? *)", "State": "ENABLED", "Targets": [ { diff --git a/libs/awscdk/test/schedule.test.ts b/libs/awscdk/test/schedule.test.ts index 5092f3c4f30..453ae1712cd 100644 --- a/libs/awscdk/test/schedule.test.ts +++ b/libs/awscdk/test/schedule.test.ts @@ -33,7 +33,7 @@ test("schedule behavior with cron", () => { `async handle(event) { console.log("Received: ", event); }` ); const schedule = new cloud.Schedule(app, "Schedule", { - cron: "0/1 * ? * *", + cron: "0/1 * * * *", }); schedule.onTick(fn); const output = app.synth(); @@ -42,7 +42,70 @@ test("schedule behavior with cron", () => { const template = Template.fromJSON(JSON.parse(output)); template.resourceCountIs("AWS::Events::Rule", 1); template.hasResourceProperties("AWS::Events::Rule", { - ScheduleExpression: "cron(0/1 * ? * * *)", + ScheduleExpression: "cron(0/1 * * * ? *)", + }); + expect(awscdkSanitize(template)).toMatchSnapshot(); +}); + +test("convert single dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = simulator.Testing.makeHandler( + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.resourceCountIs("AWS::Events::Rule", 1); + template.hasResourceProperties("AWS::Events::Rule", { + ScheduleExpression: "cron(* * ? * 0 *)", + }); + expect(awscdkSanitize(template)).toMatchSnapshot(); +}); + +test("convert the range of dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = simulator.Testing.makeHandler( + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1-7", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.resourceCountIs("AWS::Events::Rule", 1); + template.hasResourceProperties("AWS::Events::Rule", { + ScheduleExpression: "cron(* * ? * 0-6 *)", + }); + expect(awscdkSanitize(template)).toMatchSnapshot(); +}); + +test("convert the list of dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = simulator.Testing.makeHandler( + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1,3,5,7", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.resourceCountIs("AWS::Events::Rule", 1); + template.hasResourceProperties("AWS::Events::Rule", { + ScheduleExpression: "cron(* * ? * 0,2,4,6 *)", }); expect(awscdkSanitize(template)).toMatchSnapshot(); }); @@ -54,7 +117,7 @@ test("schedule with two functions", () => { `async handle(event) { console.log("Received: ", event); }` ); const schedule = new cloud.Schedule(app, "Schedule", { - cron: "0/1 * ? * *", + cron: "0/1 * * * *", }); schedule.onTick(fn); const output = app.synth(); @@ -123,7 +186,7 @@ test("schedule with rate less than 1 minute", () => { ).toThrow("rate can not be set to less than 1 minute."); }); -test("cron with Day-of-month and Day-of-week setting with *", () => { +test("cron with day of month and day of week configured at the same time", () => { // GIVEN const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); @@ -131,9 +194,7 @@ test("cron with Day-of-month and Day-of-week setting with *", () => { expect( () => new cloud.Schedule(app, "Schedule", { - cron: "0/1 * * * *", + cron: "* * 1 * 1", }) - ).toThrow( - "cannot use * in both the Day-of-month and Day-of-week fields. If you use it in one, you must use ? in the other" - ); + ).toThrow("Cannot restrict both 'day-of-month' and 'day-of-week' in a cron expression, at least one must be '*'"); }); diff --git a/libs/wingc/src/ast.rs b/libs/wingc/src/ast.rs index f8ea0436218..6f45c3044a1 100644 --- a/libs/wingc/src/ast.rs +++ b/libs/wingc/src/ast.rs @@ -279,7 +279,7 @@ pub enum FunctionBody { /// The function body implemented within a Wing scope. Statements(Scope), /// The `extern` modifier value, pointing to an external implementation file - External(String), + External(Utf8PathBuf), } #[derive(Debug)] diff --git a/libs/wingc/src/docs.rs b/libs/wingc/src/docs.rs index c57b6da02c4..81087211b38 100644 --- a/libs/wingc/src/docs.rs +++ b/libs/wingc/src/docs.rs @@ -45,6 +45,49 @@ impl Docs { ..Default::default() } } + + pub fn as_jsdoc_comment(&self) -> Option { + let mut markdown = CodeMaker::default(); + let mut has_data = false; + markdown.line("/** "); + + if let Some(s) = &self.summary { + has_data = true; + markdown.append(s); + } + + if let Some(s) = &self.remarks { + has_data = true; + markdown.line(s); + } + + if let Some(s) = &self.example { + has_data = true; + markdown.line(s); + } + + if let Some(s) = &self.returns { + has_data = true; + markdown.line(format!("@returns {s}")); + } + + if let Some(s) = &self.deprecated { + has_data = true; + markdown.line(format!("@deprecated {s}")); + } + + if let Some(s) = &self.see { + has_data = true; + markdown.line(format!("@see {s}")); + } + + if has_data { + markdown.append(" */"); + Some(markdown.to_string()) + } else { + None + } + } } impl Documented for SymbolKind { diff --git a/libs/wingc/src/dtsify/extern_dtsify.rs b/libs/wingc/src/dtsify/extern_dtsify.rs new file mode 100644 index 00000000000..b9c931322e9 --- /dev/null +++ b/libs/wingc/src/dtsify/extern_dtsify.rs @@ -0,0 +1,351 @@ +use std::collections::HashMap; + +use camino::Utf8PathBuf; +use const_format::formatcp; +use itertools::Itertools; + +use crate::{ + ast::{AccessModifier, Phase}, + dtsify::{ignore_member_phase, TYPE_INFLIGHT_POSTFIX}, + files::{update_file, FilesError}, + jsify::codemaker::CodeMaker, + type_check::*, + SymbolEnv, WINGSDK_ASSEMBLY_NAME, WINGSDK_DURATION, +}; + +const DURATION_FQN: &str = formatcp!("{WINGSDK_ASSEMBLY_NAME}.{WINGSDK_DURATION}"); + +/// Generates a self-contained .d.ts file for a given extern file. +pub struct ExternDTSifier<'a> { + libraries: &'a SymbolEnv, + extern_file: &'a Utf8PathBuf, + extern_file_env: &'a SymbolEnvOrNamespace, + /// The type information for any named types seen along the way that need to be hoisted to the top of the file + hoisted_types: CodeMaker, + /// Named types ecountered so far that have been hoisted. + /// The key is a psuedo-FQN and the value is the actual name to use in the .d.ts file. This name is based on name_counter since there may be duplicates. + known_types: HashMap, + /// Because all types will be in the same namespace, if you types have the same name this map will be used to disambiguate them by incrementing the counter. + name_counter: HashMap, +} + +/// Checks if given file has a valid extension to be considered an extern file +pub fn is_extern_file(file: &Utf8PathBuf) -> bool { + if let Some(ext) = file.extension() { + match ext { + "js" | "cjs" | "mjs" | "jsx" | "ts" | "cts" | "mts" | "tsx" => true, + _ => false, + } + } else { + false + } +} + +impl<'a> ExternDTSifier<'a> { + pub fn new( + extern_file: &'a Utf8PathBuf, + extern_file_env: &'a SymbolEnvOrNamespace, + libraries: &'a SymbolEnv, + ) -> Self { + Self { + libraries, + extern_file, + extern_file_env, + known_types: HashMap::new(), + name_counter: HashMap::new(), + hoisted_types: CodeMaker::default(), + } + } + + pub fn dtsify(&mut self) -> Result<(), FilesError> { + let mut dts = CodeMaker::default(); + dts.open("export default interface extern {"); + + if let SymbolEnvOrNamespace::SymbolEnv(extern_env) = self.extern_file_env { + for env_entry in extern_env.iter(false) { + if let Some(variable_info) = env_entry.1.as_variable() { + let sym_type = variable_info.type_; + let func_phase = sym_type.as_function_sig().unwrap().phase; + + let type_string = self.dtsify_type(sym_type, matches!(func_phase, Phase::Inflight)); + dts.line(format!("{}: {},", env_entry.0, type_string)); + } + } + } + dts.close("}"); + + // add all the known types we found + dts.line(self.hoisted_types.to_string()); + + let dts_filename = self.extern_file.with_extension("extern.d.ts"); + + update_file(&dts_filename, &dts.to_string()) + } + + fn dtsify_type(&mut self, type_: TypeRef, is_inflight: bool) -> String { + match &*type_ { + Type::Anything => "any".to_string(), + Type::Number => "number".to_string(), + Type::String => "string".to_string(), + Type::Boolean => "boolean".to_string(), + Type::Void => "void".to_string(), + Type::Nil => "undefined".to_string(), + Type::Json(_) => "Readonly".to_string(), + Type::MutJson => "any".to_string(), + Type::Duration => { + let duration_type = self + .libraries + .lookup_nested_str(DURATION_FQN, None) + .unwrap() + .0 + .as_type() + .unwrap(); + self.dtsify_type(duration_type, false) + } + Type::Optional(t) => format!("({}) | undefined", self.dtsify_type(*t, is_inflight)), + Type::Array(t) => format!("(readonly ({})[])", self.dtsify_type(*t, is_inflight)), + Type::MutArray(t) => format!("({})[]", self.dtsify_type(*t, is_inflight)), + Type::Map(t) => format!("Readonly>", self.dtsify_type(*t, is_inflight)), + Type::MutMap(t) => format!("Record", self.dtsify_type(*t, is_inflight)), + Type::Set(t) => format!("Readonly>", self.dtsify_type(*t, is_inflight)), + Type::MutSet(t) => format!("Set<{}>", self.dtsify_type(*t, is_inflight)), + Type::Function(f) => self.dtsify_function_signature(&f, is_inflight), + Type::Class(_) | Type::Interface(_) | Type::Struct(_) | Type::Enum(_) => { + self.resolve_named_type(type_, is_inflight) + } + Type::Inferred(_) | Type::Unresolved => { + panic!("Extern must use resolved types") + } + } + } + + fn resolve_named_type(&mut self, type_: TypeRef, is_inflight: bool) -> String { + let fqn = match &*type_ { + Type::Class(c) => c.fqn.as_ref().unwrap_or(&c.name.span.file_id), + Type::Interface(i) => i.fqn.as_ref().unwrap_or(&i.name.span.file_id), + Type::Struct(s) => s.fqn.as_ref().unwrap_or(&s.name.span.file_id), + Type::Enum(e) => &e.name.span.file_id, + _ => panic!("Not a named type"), + }; + let base_name = match &*type_ { + Type::Class(c) => { + if is_inflight { + format!("{}{}", c.name.name, TYPE_INFLIGHT_POSTFIX) + } else { + c.name.name.clone() + } + } + Type::Interface(i) => { + if is_inflight { + format!("{}{}", i.name.name, TYPE_INFLIGHT_POSTFIX) + } else { + i.name.name.clone() + } + } + Type::Struct(s) => s.name.name.clone(), + Type::Enum(e) => e.name.name.clone(), + _ => panic!("Not a named type"), + }; + let type_key = format!("{fqn}|{base_name}"); + + if let Some(name) = self.known_types.get(&type_key) { + name.clone() + } else { + let name_counter = self.name_counter.get(&base_name).unwrap_or(&0); + let name = if *name_counter == 0 { + base_name.to_string() + } else { + format!("{base_name}{name_counter}") + }; + + self.name_counter.insert(base_name, name_counter + 1); + self.known_types.insert(type_key, name.clone()); + + let type_code = match &*type_ { + Type::Class(c) => self.dtsify_class(c, is_inflight), + Type::Interface(i) => self.dtsify_interface(i, is_inflight), + Type::Struct(s) => self.dtsify_struct(s), + Type::Enum(e) => self.dtsify_enum(e), + _ => panic!("Not a named type"), + }; + self.hoisted_types.line(type_code); + + name + } + } + + fn dtsify_function_signature(&mut self, f: &FunctionSignature, is_inflight: bool) -> String { + let args = self.dtsify_parameters(&f.parameters, is_inflight); + + let is_inflight = matches!(f.phase, Phase::Inflight); + + let return_type = self.dtsify_type(f.return_type, is_inflight); + let return_type = if is_inflight { + format!("Promise<{return_type}>") + } else { + return_type + }; + + format!("({args}) => {return_type}") + } + + fn dtsify_enum(&mut self, enum_: &Enum) -> CodeMaker { + let mut code = CodeMaker::default(); + + if let Some(docs) = &enum_.docs.as_jsdoc_comment() { + code.line(docs); + } + code.open(format!("export enum {} {{", enum_.name.name)); + + for (i, variant) in enum_.values.iter().enumerate() { + code.line(format!("{variant} = {i},")); + } + + code.close("}"); + + code + } + + fn dtsify_struct(&mut self, struct_: &Struct) -> CodeMaker { + let mut code = CodeMaker::default(); + if let Some(docs) = &struct_.docs.as_jsdoc_comment() { + code.line(docs); + } + code.open(format!("export interface {} {{", struct_.name.name)); + + code.line(self.dtsify_inner_classlike(struct_, false)); + + code.close("}"); + + code + } + + fn dtsify_interface(&mut self, interface: &Interface, is_inflight: bool) -> CodeMaker { + let mut code = CodeMaker::default(); + let interface_name = if is_inflight { + format!("{}{TYPE_INFLIGHT_POSTFIX}", &interface.name.name) + } else { + interface.name.name.to_string() + }; + + if let Some(docs) = &interface.docs.as_jsdoc_comment() { + code.line(docs); + } + code.line(format!("export interface {interface_name}")); + if !interface.extends.is_empty() { + code.append(" extends "); + code.append( + &interface + .extends + .iter() + .map(|udt| self.dtsify_type(*udt, is_inflight)) + .join(", "), + ); + } + + code.append(" {"); + code.indent(); + + code.line(self.dtsify_inner_classlike(interface, is_inflight)); + + code.close("}"); + + code + } + + fn dtsify_class(&mut self, class: &Class, is_inflight: bool) -> CodeMaker { + let mut code = CodeMaker::default(); + let class_name = if is_inflight { + format!("{}{TYPE_INFLIGHT_POSTFIX}", class.name) + } else { + class.name.name.to_string() + }; + + if let Some(docs) = &class.docs.as_jsdoc_comment() { + code.line(docs); + } + code.line(format!("export class {class_name}")); + if let Some(parent) = &class.parent { + code.append(" extends "); + code.append(self.dtsify_type(*parent, is_inflight)); + } + + if !class.implements.is_empty() { + code.append(" implements "); + code.append( + &class + .implements + .iter() + .map(|udt| self.dtsify_type(*udt, is_inflight)) + .join(", "), + ); + } + + code.append(" {"); + code.indent(); + + code.line(self.dtsify_inner_classlike(class, is_inflight)); + + code.close("}"); + code + } + + fn dtsify_parameters(&mut self, arg_list: &Vec, is_inflight: bool) -> String { + let mut args = vec![]; + + for (i, arg) in arg_list.iter().enumerate() { + let arg_name = if arg.name.is_empty() { + // function type annotations don't always have names + format!("arg{}", i) + } else { + arg.name.clone() + }; + + args.push(format!( + "{arg_name}{}: {}", + if arg.typeref.is_option() { "?" } else { "" }, + self.dtsify_type(arg.typeref, is_inflight) + )); + } + args.join(", ") + } + + fn dtsify_inner_classlike(&mut self, classlike: &impl ClassLike, is_inflight: bool) -> CodeMaker { + let mut code = CodeMaker::default(); + for member_var in classlike.get_env().iter(false).filter_map(|(_, kind, lookup)| { + if lookup.init || !matches!(lookup.access, AccessModifier::Public) { + return None; + } + + let variable = kind.as_variable()?; + + if let Some(sig) = variable.type_.as_function_sig() { + if ignore_member_phase(sig.phase, is_inflight) { + return None; + } + } else { + if ignore_member_phase(variable.phase, is_inflight) { + return None; + } + } + + if variable.kind != VariableKind::InstanceMember { + None + } else { + Some(variable) + } + }) { + if let Some(docs) = member_var.docs.as_ref().and_then(|d| d.as_jsdoc_comment()) { + code.line(docs); + } + code.line(format!( + "{}{}{}: {};", + if member_var.reassignable { "" } else { "readonly " }, + member_var.name, + if member_var.type_.is_option() { "?" } else { "" }, + self.dtsify_type(member_var.type_, is_inflight) + )); + } + code + } +} diff --git a/libs/wingc/src/dtsify/mod.rs b/libs/wingc/src/dtsify/mod.rs index 54e57abe883..1c18d3540e7 100644 --- a/libs/wingc/src/dtsify/mod.rs +++ b/libs/wingc/src/dtsify/mod.rs @@ -8,8 +8,9 @@ use crate::{ ast::*, diagnostic::report_diagnostic, file_graph::FileGraph, files::Files, jsify::codemaker::CodeMaker, type_check::Types, WINGSDK_ASSEMBLY_NAME, }; +pub mod extern_dtsify; -const TYPE_INFLIGHT_POSTFIX: &str = "$Inflight"; +pub const TYPE_INFLIGHT_POSTFIX: &str = "$Inflight"; const TYPE_INTERNAL_NAMESPACE: &str = "$internal"; const TYPE_STD: &str = "std"; @@ -389,11 +390,11 @@ impl<'a> DTSifier<'a> { } } -fn ignore_member_phase(phase: Phase, is_inflight_client: bool) -> bool { +pub fn ignore_member_phase(phase: Phase, is_inflight: bool) -> bool { // If we're an inflight client, we want to ignore preflight members // Or // If we're a preflight client, we want to ignore inflight members - (is_inflight_client && matches!(phase, Phase::Preflight)) || (!is_inflight_client && matches!(phase, Phase::Inflight)) + (is_inflight && matches!(phase, Phase::Preflight)) || (!is_inflight && matches!(phase, Phase::Inflight)) } #[test] diff --git a/libs/wingc/src/files.rs b/libs/wingc/src/files.rs index b4558ed7d05..646636aebf8 100644 --- a/libs/wingc/src/files.rs +++ b/libs/wingc/src/files.rs @@ -84,14 +84,33 @@ impl Files { fs::create_dir_all(parent).map_err(FilesError::IoError)?; } - let mut file = File::create(full_path).map_err(FilesError::IoError)?; - file.write_all(content.as_bytes()).map_err(FilesError::IoError)?; - file.flush().map_err(FilesError::IoError)?; + write_file(&full_path, content)?; } Ok(()) } } +/// Write file to disk +pub fn write_file(path: &Utf8Path, content: &str) -> Result<(), FilesError> { + let mut file = File::create(path).map_err(FilesError::IoError)?; + file.write_all(content.as_bytes()).map_err(FilesError::IoError)?; + file.flush().map_err(FilesError::IoError)?; + Ok(()) +} + +// Check if the content of a file is the same as existing content. If so, skip writing the file. +pub fn update_file(path: &Utf8Path, content: &str) -> Result<(), FilesError> { + let Ok(existing_content) = fs::read(path) else { + return write_file(path, content); + }; + + if existing_content != content.as_bytes() { + write_file(path, content) + } else { + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -165,4 +184,39 @@ mod tests { let file1_content = fs::read_to_string(file1_path).expect("Failed to read file"); assert_eq!(file1_content, "content1"); } + #[test] + fn test_update_file() { + let temp_dir = tempfile::tempdir().expect("Failed to create temporary directory"); + let out_dir = Utf8Path::from_path(temp_dir.path()).expect("invalid unicode path"); + + let file_path = out_dir.join("file"); + + // Write the file for the first time + assert!(update_file(&file_path, "content").is_ok()); + + // Update the file + let new_content = "new content"; + assert!(update_file(&file_path, new_content).is_ok()); + + // Verify that the file was updated + let file_content = fs::read_to_string(file_path.clone()).expect("Failed to read file"); + assert_eq!(file_content, new_content); + let last_updated = file_path + .metadata() + .expect("Failed to get file metadata") + .modified() + .expect("Failed to get file modified time"); + + // Try to update the file with the same content + assert!(update_file(&file_path, new_content).is_ok()); + + // Verify that the file was not updated (check the timestamps via stat) + let updated = file_path + .metadata() + .expect("Failed to get file metadata") + .modified() + .expect("Failed to get file modified time"); + + assert_eq!(updated, last_updated); + } } diff --git a/libs/wingc/src/jsify.rs b/libs/wingc/src/jsify.rs index 1dda49a9edc..16406647d00 100644 --- a/libs/wingc/src/jsify.rs +++ b/libs/wingc/src/jsify.rs @@ -1318,7 +1318,6 @@ impl<'a> JSifier<'a> { let body = match &func_def.body { FunctionBody::Statements(scope) => self.jsify_scope_body(scope, ctx), FunctionBody::External(extern_path) => { - let extern_path = Utf8Path::new(extern_path); let entrypoint_is_file = self.compilation_init_path.is_file(); let entrypoint_dir = if entrypoint_is_file { self.compilation_init_path.parent().unwrap() diff --git a/libs/wingc/src/lib.rs b/libs/wingc/src/lib.rs index 36850203ce6..e61a502a928 100644 --- a/libs/wingc/src/lib.rs +++ b/libs/wingc/src/lib.rs @@ -12,6 +12,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use closure_transform::ClosureTransformer; use comp_ctx::set_custom_panic_hook; use diagnostic::{found_errors, report_diagnostic, Diagnostic}; +use dtsify::extern_dtsify::{is_extern_file, ExternDTSifier}; use file_graph::FileGraph; use files::Files; use fold::Fold; @@ -205,10 +206,10 @@ pub unsafe extern "C" fn wingc_compile(ptr: u32, len: u32) -> u64 { let results = compile(project_dir, source_path, None, output_dir); - if results.is_err() { - WASM_RETURN_ERROR + if let Ok(results) = results { + string_to_combined_ptr(serde_json::to_string(&results).unwrap()) } else { - string_to_combined_ptr(serde_json::to_string(&results.unwrap()).unwrap()) + WASM_RETURN_ERROR } } @@ -359,9 +360,11 @@ pub fn compile( let scope = asts.get_mut(file).expect("matching AST not found"); jsifier.jsify(file, &scope); } - match jsifier.output_files.borrow().emit_files(out_dir) { - Ok(()) => {} - Err(err) => report_diagnostic(err.into()), + if !found_errors() { + match jsifier.output_files.borrow().emit_files(out_dir) { + Ok(()) => {} + Err(err) => report_diagnostic(err.into()), + } } // -- DTSIFICATION PHASE -- @@ -372,10 +375,25 @@ pub fn compile( let scope = asts.get_mut(file).expect("matching AST not found"); dtsifier.dtsify(file, &scope); } - let output_files = dtsifier.output_files.borrow(); - match output_files.emit_files(out_dir) { - Ok(()) => {} - Err(err) => report_diagnostic(err.into()), + if !found_errors() { + let output_files = dtsifier.output_files.borrow(); + match output_files.emit_files(out_dir) { + Ok(()) => {} + Err(err) => report_diagnostic(err.into()), + } + } + } + + // -- EXTERN DTSIFICATION PHASE -- + for source_files_env in &types.source_file_envs { + if is_extern_file(source_files_env.0) { + let mut extern_dtsifier = ExternDTSifier::new(source_files_env.0, source_files_env.1, &types.libraries); + if !found_errors() { + match extern_dtsifier.dtsify() { + Ok(()) => {} + Err(err) => report_diagnostic(err.into()), + }; + } } } @@ -392,7 +410,7 @@ pub fn compile( }) .collect::>(); - return Ok(CompilerOutput { imported_namespaces }); + Ok(CompilerOutput { imported_namespaces }) } pub fn is_absolute_path(path: &Utf8Path) -> bool { diff --git a/libs/wingc/src/parser.rs b/libs/wingc/src/parser.rs index 051538ea333..31945b6b531 100644 --- a/libs/wingc/src/parser.rs +++ b/libs/wingc/src/parser.rs @@ -1615,7 +1615,7 @@ impl<'s> Parser<'s> { .report(); } - FunctionBody::External(file_path.to_string()) + FunctionBody::External(file_path) } else { FunctionBody::Statements(self.build_scope(&self.get_child_field(func_def_node, "block")?, phase)) }; diff --git a/libs/wingc/src/type_check.rs b/libs/wingc/src/type_check.rs index c274525dd1c..4f7623860b5 100644 --- a/libs/wingc/src/type_check.rs +++ b/libs/wingc/src/type_check.rs @@ -4839,13 +4839,63 @@ impl<'a> TypeChecker<'a> { tc.inner_scopes.push((scope, tc.ctx.clone())); } - if let FunctionBody::External(_) = &method_def.body { + if let FunctionBody::External(extern_path) = &method_def.body { if !method_def.is_static { tc.spanned_error( method_name, "Extern methods must be declared \"static\" (they cannot access instance members)", ); } + if !tc.types.source_file_envs.contains_key(extern_path) { + let new_env = tc.types.add_symbol_env(SymbolEnv::new( + None, + SymbolEnvKind::Type(tc.types.void()), + method_sig.phase, + 0, + )); + tc.types + .source_file_envs + .insert(extern_path.clone(), SymbolEnvOrNamespace::SymbolEnv(new_env)); + } + + if let Some(SymbolEnvOrNamespace::SymbolEnv(extern_env)) = tc.types.source_file_envs.get_mut(extern_path) { + let lookup = extern_env.lookup(method_name, None); + if let Some(lookup) = lookup { + // check if it's the same type + if let Some(lookup) = lookup.as_variable() { + if !lookup.type_.is_same_type_as(method_type) { + report_diagnostic(Diagnostic { + message: "extern type must be the same in all usages".to_string(), + span: Some(method_name.span.clone()), + annotations: vec![DiagnosticAnnotation { + message: "First declared here".to_string(), + span: lookup.name.span.clone(), + }], + hints: vec![format!("Change type to match first declaration: {}", lookup.type_)], + }); + } + } else { + panic!("Expected extern to be a variable"); + } + } else { + extern_env + .define( + method_name, + SymbolKind::Variable(VariableInfo { + name: method_name.clone(), + type_: *method_type, + access: method_def.access, + phase: method_def.signature.phase, + docs: None, + kind: VariableKind::StaticMember, + reassignable: false, + }), + method_def.access, + StatementIdx::Top, + ) + .expect("Expected extern to be defined"); + } + } } }, ); diff --git a/libs/wingsdk/.projenrc.ts b/libs/wingsdk/.projenrc.ts index dd182e0d22b..c88de57f158 100644 --- a/libs/wingsdk/.projenrc.ts +++ b/libs/wingsdk/.projenrc.ts @@ -7,7 +7,7 @@ const AWS_SDK_VERSION = "3.490.0"; const CDKTF_PROVIDERS = [ "aws@~>5.31.0", "random@~>3.5.1", - "azurerm@~>3.54.0", + "azurerm@~>3.96.0", "google@~>5.10.0", ]; diff --git a/libs/wingsdk/cdktf.json b/libs/wingsdk/cdktf.json index 9438cd721e2..9c028d3b83e 100644 --- a/libs/wingsdk/cdktf.json +++ b/libs/wingsdk/cdktf.json @@ -4,7 +4,7 @@ "terraformProviders": [ "aws@~>5.31.0", "random@~>3.5.1", - "azurerm@~>3.54.0", + "azurerm@~>3.96.0", "google@~>5.10.0" ], "codeMakerOutput": "src/.gen", diff --git a/libs/wingsdk/src/cloud/schedule.ts b/libs/wingsdk/src/cloud/schedule.ts index 3f1007be7c6..a452793402e 100644 --- a/libs/wingsdk/src/cloud/schedule.ts +++ b/libs/wingsdk/src/cloud/schedule.ts @@ -24,7 +24,15 @@ export interface ScheduleProps { /** * Trigger events according to a cron schedule using the UNIX cron format. Timezone is UTC. * [minute] [hour] [day of month] [month] [day of week] - * @example "0/1 * ? * *" + * '*' means all possible values. + * '-' means a range of values. + * ',' means a list of values. + * [minute] allows 0-59. + * [hour] allows 0-23. + * [day of month] allows 1-31. + * [month] allows 1-12 or JAN-DEC. + * [day of week] allows 0-6 or SUN-SAT. + * @example "* * * * *" * @default undefined */ readonly cron?: string; @@ -67,11 +75,6 @@ export class Schedule extends Resource { "cron string must be UNIX cron format [minute] [hour] [day of month] [month] [day of week]" ); } - if (cron && cron.split(" ")[2] == "*" && cron.split(" ")[4] == "*") { - throw new Error( - "cannot use * in both the Day-of-month and Day-of-week fields. If you use it in one, you must use ? in the other" - ); - } } /** diff --git a/libs/wingsdk/src/shared-aws/schedule.ts b/libs/wingsdk/src/shared-aws/schedule.ts new file mode 100644 index 00000000000..424fc6f4cd7 --- /dev/null +++ b/libs/wingsdk/src/shared-aws/schedule.ts @@ -0,0 +1,64 @@ +/** + * Convert Unix cron to AWS cron + */ +export const convertUnixCronToAWSCron = (cron: string) => { + const minute = cron.split(" ")[0]; + const hour = cron.split(" ")[1]; + let dayOfMonth = cron.split(" ")[2]; + const month = cron.split(" ")[3]; + let dayOfWeek = cron.split(" ")[4]; + + /* + * The implementation of cron on AWS does not allow [day of month] and [day of week] + * to have the character '*' at the same time. + * Therefore, [day of week] will be replaced by '?'. + */ + if (cron && dayOfMonth == "*" && dayOfWeek == "*") { + dayOfWeek = "?"; + } + + if (cron && dayOfMonth !== "*" && dayOfWeek !== "*") { + throw new Error( + "Cannot restrict both 'day-of-month' and 'day-of-week' in a cron expression, at least one must be '*'" + ); + } + + if (dayOfWeek !== "*" && dayOfWeek !== "?") { + dayOfMonth = "?"; + if (/\d/.test(dayOfWeek)) { + dayOfWeek = convertDayOfWeekFromUnixToAWS(dayOfWeek); + } + } + + /* + * The schedule cron string is Unix cron format: [minute] [hour] [day of month] [month] [day of week] + * AWS EventBridge Schedule uses a 6 field format which includes year: [minute] [hour] [day of month] [month] [day of week] [year] + * https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based + * + * We append * to the cron string for year field. + */ + return ( + minute + + " " + + hour + + " " + + dayOfMonth + + " " + + month + + " " + + dayOfWeek + + " *" + ); +}; + +const convertDayOfWeekFromUnixToAWS = (dayOfWeek: string): string => { + const numbers = dayOfWeek.match(/\d+/g); + + if (numbers) { + for (const number of numbers) { + dayOfWeek = dayOfWeek.replace(number, (parseInt(number) - 1).toString()); + } + } + + return dayOfWeek; +}; diff --git a/libs/wingsdk/src/target-sim/util.ts b/libs/wingsdk/src/target-sim/util.ts index 71a78a6cbc4..17074dec81b 100644 --- a/libs/wingsdk/src/target-sim/util.ts +++ b/libs/wingsdk/src/target-sim/util.ts @@ -75,9 +75,7 @@ export function convertDurationToCronExpression(dur: Duration): string { // for now we just use * for day, month, and year const dayInMonth = "*"; const month = "*"; - // if day of month is "*", day of week should be "?" - // https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html - const dayOfWeek = "?"; + const dayOfWeek = "*"; // Generate cron string based on the duration const cronString = `${minute} ${hour} ${dayInMonth} ${month} ${dayOfWeek}`; diff --git a/libs/wingsdk/src/target-tf-aws/schedule.ts b/libs/wingsdk/src/target-tf-aws/schedule.ts index c660b26b61d..9d0d515cce6 100644 --- a/libs/wingsdk/src/target-tf-aws/schedule.ts +++ b/libs/wingsdk/src/target-tf-aws/schedule.ts @@ -7,6 +7,7 @@ import { CloudwatchEventTarget } from "../.gen/providers/aws/cloudwatch-event-ta import * as cloud from "../cloud"; import * as core from "../core"; import { convertBetweenHandlers } from "../shared/convert"; +import { convertUnixCronToAWSCron } from "../shared-aws/schedule"; import { Node } from "../std"; /** @@ -24,21 +25,13 @@ export class Schedule extends cloud.Schedule { const { rate, cron } = props; - /* - * The schedule cron string is Unix cron format: [minute] [hour] [day of month] [month] [day of week] - * AWS EventBridge Schedule uses a 6 field format which includes year: [minute] [hour] [day of month] [month] [day of week] [year] - * https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based - * - * We append * to the cron string for year field. - */ this.scheduleExpression = rate ? rate.minutes === 1 ? `rate(${rate.minutes} minute)` : `rate(${rate.minutes} minutes)` - : `cron(${cron} *)`; + : `cron(${convertUnixCronToAWSCron(cron!)})`; this.rule = new CloudwatchEventRule(this, "Schedule", { - isEnabled: true, scheduleExpression: this.scheduleExpression, }); } diff --git a/libs/wingsdk/test/target-sim/__snapshots__/schedule.test.ts.snap b/libs/wingsdk/test/target-sim/__snapshots__/schedule.test.ts.snap index 00dcc05c893..c255a568385 100644 --- a/libs/wingsdk/test/target-sim/__snapshots__/schedule.test.ts.snap +++ b/libs/wingsdk/test/target-sim/__snapshots__/schedule.test.ts.snap @@ -13,7 +13,7 @@ exports[`create a schedule 1`] = ` "attrs": {}, "path": "root/my_schedule", "props": { - "cronExpression": "*/1 * * * ?", + "cronExpression": "*/1 * * * *", }, "type": "@winglang/sdk.cloud.Schedule", }, @@ -182,7 +182,7 @@ console.log("Hello from schedule!"); "attrs": {}, "path": "root/my_schedule", "props": { - "cronExpression": "* */3 * * ?", + "cronExpression": "* */3 * * *", }, "type": "@winglang/sdk.cloud.Schedule", }, @@ -392,7 +392,7 @@ console.log("Hello from schedule!"); "attrs": {}, "path": "root/my_schedule", "props": { - "cronExpression": "*/10 * * * ?", + "cronExpression": "*/10 * * * *", }, "type": "@winglang/sdk.cloud.Schedule", }, @@ -602,7 +602,7 @@ console.log("Hello from schedule!"); "attrs": {}, "path": "root/my_schedule", "props": { - "cronExpression": "* * * * ?", + "cronExpression": "* * * * *", }, "type": "@winglang/sdk.cloud.Schedule", }, diff --git a/libs/wingsdk/test/target-sim/schedule.test.ts b/libs/wingsdk/test/target-sim/schedule.test.ts index 1a6624e4d0f..8bc8d691bb4 100644 --- a/libs/wingsdk/test/target-sim/schedule.test.ts +++ b/libs/wingsdk/test/target-sim/schedule.test.ts @@ -12,7 +12,7 @@ console.log("Hello from schedule!"); test("create a schedule", async () => { // GIVEN const app = new SimApp(); - const cron = "*/1 * * * ?"; + const cron = "*/1 * * * *"; new cloud.Schedule(app, "my_schedule", { cron }); const s = await app.startSimulator(); @@ -39,7 +39,7 @@ test("schedule with one task with cron", async () => { const app = new SimApp(); const handler = Testing.makeHandler(INFLIGHT_CODE); const schedule = new cloud.Schedule(app, "my_schedule", { - cron: "* * * * ?", + cron: "* * * * *", }); schedule.onTick(handler); @@ -59,7 +59,7 @@ test("schedule with one task using rate of 10m", async () => { const schedule = new cloud.Schedule(app, "my_schedule", { rate: Duration.fromMinutes(10), }); - const expectedCron = "*/10 * * * ?"; // every 10 minutes cron expression + const expectedCron = "*/10 * * * *"; // every 10 minutes cron expression schedule.onTick(handler); const s = await app.startSimulator(); @@ -87,7 +87,7 @@ test("schedule with one task using rate of 3h", async () => { const schedule = new cloud.Schedule(app, "my_schedule", { rate: Duration.fromHours(3), }); - const expectedCron = "* */3 * * ?"; // every 3 hours cron expression + const expectedCron = "* */3 * * *"; // every 3 hours cron expression schedule.onTick(handler); const s = await app.startSimulator(); diff --git a/libs/wingsdk/test/target-sim/utils.test.ts b/libs/wingsdk/test/target-sim/utils.test.ts index cd5824ed67a..0ea5c3ea185 100644 --- a/libs/wingsdk/test/target-sim/utils.test.ts +++ b/libs/wingsdk/test/target-sim/utils.test.ts @@ -6,7 +6,7 @@ describe("convertDurationToCronExpression", () => { test("converts a duration from minutes", () => { // GIVEN const dur = Duration.fromMinutes(10); - const expectedCron = "*/10 * * * ?"; + const expectedCron = "*/10 * * * *"; // WHEN const cron = convertDurationToCronExpression(dur); @@ -17,7 +17,7 @@ describe("convertDurationToCronExpression", () => { test("converts a duration from hours", () => { const dur = Duration.fromHours(2); - const expectedCron = "* */2 * * ?"; + const expectedCron = "* */2 * * *"; // WHEN const cron = convertDurationToCronExpression(dur); @@ -29,7 +29,7 @@ describe("convertDurationToCronExpression", () => { test("converts durations with fractional hours", () => { // GIVEN const dur = Duration.fromHours(2.5); - const expectedCron = "*/30 */2 * * ?"; + const expectedCron = "*/30 */2 * * *"; // WHEN const cron = convertDurationToCronExpression(dur); diff --git a/libs/wingsdk/test/target-tf-aws/__snapshots__/schedule.test.ts.snap b/libs/wingsdk/test/target-tf-aws/__snapshots__/schedule.test.ts.snap index 1665082908f..5f054a6624f 100644 --- a/libs/wingsdk/test/target-tf-aws/__snapshots__/schedule.test.ts.snap +++ b/libs/wingsdk/test/target-tf-aws/__snapshots__/schedule.test.ts.snap @@ -1,12 +1,803 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`convert single dayOfWeek from Unix to AWS 1`] = ` +{ + "resource": { + "aws_cloudwatch_event_rule": { + "Schedule_15669BF1": { + "schedule_expression": "cron(* * ? * 0 *)", + }, + }, + "aws_cloudwatch_event_target": { + "Schedule_ScheduleTarget0_12D341DB": { + "arn": "\${aws_lambda_function.Schedule_OnTick0_958638E3.qualified_arn}", + "rule": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.name}", + }, + }, + "aws_cloudwatch_log_group": { + "Schedule_OnTick0_CloudwatchLogGroup_A06DC96E": { + "name": "/aws/lambda/OnTick0-c8e1d4a8", + "retention_in_days": 30, + }, + }, + "aws_iam_role": { + "Schedule_OnTick0_IamRole_478B0576": { + "assume_role_policy": "{"Version":"2012-10-17","Statement":[{"Action":"sts:AssumeRole","Principal":{"Service":"lambda.amazonaws.com"},"Effect":"Allow"}]}", + }, + }, + "aws_iam_role_policy": { + "Schedule_OnTick0_IamRolePolicy_708CFC38": { + "policy": "{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"none:null","Resource":"*"}]}", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_iam_role_policy_attachment": { + "Schedule_OnTick0_IamRolePolicyAttachment_5885D6B3": { + "policy_arn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_lambda_function": { + "Schedule_OnTick0_958638E3": { + "architectures": [ + "arm64", + ], + "environment": { + "variables": { + "NODE_OPTIONS": "--enable-source-maps", + "WING_FUNCTION_NAME": "OnTick0-c8e1d4a8", + }, + }, + "function_name": "OnTick0-c8e1d4a8", + "handler": "index.handler", + "memory_size": 1024, + "publish": true, + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.arn}", + "runtime": "nodejs20.x", + "s3_bucket": "\${aws_s3_bucket.Code.bucket}", + "s3_key": "\${aws_s3_object.Schedule_OnTick0_S3Object_95D0AF10.key}", + "timeout": 60, + "vpc_config": { + "security_group_ids": [], + "subnet_ids": [], + }, + }, + }, + "aws_lambda_permission": { + "Schedule_OnTick0_InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3_17682171": { + "action": "lambda:InvokeFunction", + "function_name": "\${aws_lambda_function.Schedule_OnTick0_958638E3.function_name}", + "principal": "events.amazonaws.com", + "qualifier": "\${aws_lambda_function.Schedule_OnTick0_958638E3.version}", + "source_arn": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.arn}", + }, + }, + "aws_s3_bucket": { + "Code": { + "bucket_prefix": "code-c84a50b1-", + }, + }, + "aws_s3_object": { + "Schedule_OnTick0_S3Object_95D0AF10": { + "bucket": "\${aws_s3_bucket.Code.bucket}", + "key": "", + "source": "", + }, + }, + }, +} +`; + +exports[`convert single dayOfWeek from Unix to AWS 2`] = ` +{ + "tree": { + "children": { + "root": { + "children": { + "Default": { + "children": { + "Code": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Code", + "path": "root/Default/Code", + }, + "ParameterRegistrar": { + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "ParameterRegistrar", + "path": "root/Default/ParameterRegistrar", + }, + "Schedule": { + "children": { + "OnTick0": { + "children": { + "Asset": { + "constructInfo": { + "fqn": "cdktf.TerraformAsset", + "version": "0.20.3", + }, + "id": "Asset", + "path": "root/Default/Schedule/OnTick0/Asset", + }, + "CloudwatchLogGroup": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "CloudwatchLogGroup", + "path": "root/Default/Schedule/OnTick0/CloudwatchLogGroup", + }, + "Default": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Default", + "path": "root/Default/Schedule/OnTick0/Default", + }, + "IamRole": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRole", + "path": "root/Default/Schedule/OnTick0/IamRole", + }, + "IamRolePolicy": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicy", + "path": "root/Default/Schedule/OnTick0/IamRolePolicy", + }, + "IamRolePolicyAttachment": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicyAttachment", + "path": "root/Default/Schedule/OnTick0/IamRolePolicyAttachment", + }, + "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + "path": "root/Default/Schedule/OnTick0/InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + }, + "S3Object": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "S3Object", + "path": "root/Default/Schedule/OnTick0/S3Object", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud function (FaaS)", + "title": "Function", + }, + "id": "OnTick0", + "path": "root/Default/Schedule/OnTick0", + }, + "Schedule": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Schedule", + "path": "root/Default/Schedule/Schedule", + }, + "ScheduleTarget0": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "ScheduleTarget0", + "path": "root/Default/Schedule/ScheduleTarget0", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud schedule to trigger events at regular intervals", + "title": "Schedule", + }, + "id": "Schedule", + "path": "root/Default/Schedule", + }, + "aws": { + "constructInfo": { + "fqn": "cdktf.TerraformProvider", + "version": "0.20.3", + }, + "id": "aws", + "path": "root/Default/aws", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "Default", + "path": "root/Default", + }, + "backend": { + "constructInfo": { + "fqn": "cdktf.LocalBackend", + "version": "0.20.3", + }, + "id": "backend", + "path": "root/backend", + }, + }, + "constructInfo": { + "fqn": "cdktf.TerraformStack", + "version": "0.20.3", + }, + "id": "root", + "path": "root", + }, + }, + "constructInfo": { + "fqn": "cdktf.App", + "version": "0.20.3", + }, + "id": "App", + "path": "", + }, + "version": "tree-0.1", +} +`; + +exports[`convert the list of dayOfWeek from Unix to AWS 1`] = ` +{ + "resource": { + "aws_cloudwatch_event_rule": { + "Schedule_15669BF1": { + "schedule_expression": "cron(* * ? * 0,2,4,6 *)", + }, + }, + "aws_cloudwatch_event_target": { + "Schedule_ScheduleTarget0_12D341DB": { + "arn": "\${aws_lambda_function.Schedule_OnTick0_958638E3.qualified_arn}", + "rule": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.name}", + }, + }, + "aws_cloudwatch_log_group": { + "Schedule_OnTick0_CloudwatchLogGroup_A06DC96E": { + "name": "/aws/lambda/OnTick0-c8e1d4a8", + "retention_in_days": 30, + }, + }, + "aws_iam_role": { + "Schedule_OnTick0_IamRole_478B0576": { + "assume_role_policy": "{"Version":"2012-10-17","Statement":[{"Action":"sts:AssumeRole","Principal":{"Service":"lambda.amazonaws.com"},"Effect":"Allow"}]}", + }, + }, + "aws_iam_role_policy": { + "Schedule_OnTick0_IamRolePolicy_708CFC38": { + "policy": "{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"none:null","Resource":"*"}]}", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_iam_role_policy_attachment": { + "Schedule_OnTick0_IamRolePolicyAttachment_5885D6B3": { + "policy_arn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_lambda_function": { + "Schedule_OnTick0_958638E3": { + "architectures": [ + "arm64", + ], + "environment": { + "variables": { + "NODE_OPTIONS": "--enable-source-maps", + "WING_FUNCTION_NAME": "OnTick0-c8e1d4a8", + }, + }, + "function_name": "OnTick0-c8e1d4a8", + "handler": "index.handler", + "memory_size": 1024, + "publish": true, + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.arn}", + "runtime": "nodejs20.x", + "s3_bucket": "\${aws_s3_bucket.Code.bucket}", + "s3_key": "\${aws_s3_object.Schedule_OnTick0_S3Object_95D0AF10.key}", + "timeout": 60, + "vpc_config": { + "security_group_ids": [], + "subnet_ids": [], + }, + }, + }, + "aws_lambda_permission": { + "Schedule_OnTick0_InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3_17682171": { + "action": "lambda:InvokeFunction", + "function_name": "\${aws_lambda_function.Schedule_OnTick0_958638E3.function_name}", + "principal": "events.amazonaws.com", + "qualifier": "\${aws_lambda_function.Schedule_OnTick0_958638E3.version}", + "source_arn": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.arn}", + }, + }, + "aws_s3_bucket": { + "Code": { + "bucket_prefix": "code-c84a50b1-", + }, + }, + "aws_s3_object": { + "Schedule_OnTick0_S3Object_95D0AF10": { + "bucket": "\${aws_s3_bucket.Code.bucket}", + "key": "", + "source": "", + }, + }, + }, +} +`; + +exports[`convert the list of dayOfWeek from Unix to AWS 2`] = ` +{ + "tree": { + "children": { + "root": { + "children": { + "Default": { + "children": { + "Code": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Code", + "path": "root/Default/Code", + }, + "ParameterRegistrar": { + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "ParameterRegistrar", + "path": "root/Default/ParameterRegistrar", + }, + "Schedule": { + "children": { + "OnTick0": { + "children": { + "Asset": { + "constructInfo": { + "fqn": "cdktf.TerraformAsset", + "version": "0.20.3", + }, + "id": "Asset", + "path": "root/Default/Schedule/OnTick0/Asset", + }, + "CloudwatchLogGroup": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "CloudwatchLogGroup", + "path": "root/Default/Schedule/OnTick0/CloudwatchLogGroup", + }, + "Default": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Default", + "path": "root/Default/Schedule/OnTick0/Default", + }, + "IamRole": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRole", + "path": "root/Default/Schedule/OnTick0/IamRole", + }, + "IamRolePolicy": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicy", + "path": "root/Default/Schedule/OnTick0/IamRolePolicy", + }, + "IamRolePolicyAttachment": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicyAttachment", + "path": "root/Default/Schedule/OnTick0/IamRolePolicyAttachment", + }, + "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + "path": "root/Default/Schedule/OnTick0/InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + }, + "S3Object": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "S3Object", + "path": "root/Default/Schedule/OnTick0/S3Object", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud function (FaaS)", + "title": "Function", + }, + "id": "OnTick0", + "path": "root/Default/Schedule/OnTick0", + }, + "Schedule": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Schedule", + "path": "root/Default/Schedule/Schedule", + }, + "ScheduleTarget0": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "ScheduleTarget0", + "path": "root/Default/Schedule/ScheduleTarget0", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud schedule to trigger events at regular intervals", + "title": "Schedule", + }, + "id": "Schedule", + "path": "root/Default/Schedule", + }, + "aws": { + "constructInfo": { + "fqn": "cdktf.TerraformProvider", + "version": "0.20.3", + }, + "id": "aws", + "path": "root/Default/aws", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "Default", + "path": "root/Default", + }, + "backend": { + "constructInfo": { + "fqn": "cdktf.LocalBackend", + "version": "0.20.3", + }, + "id": "backend", + "path": "root/backend", + }, + }, + "constructInfo": { + "fqn": "cdktf.TerraformStack", + "version": "0.20.3", + }, + "id": "root", + "path": "root", + }, + }, + "constructInfo": { + "fqn": "cdktf.App", + "version": "0.20.3", + }, + "id": "App", + "path": "", + }, + "version": "tree-0.1", +} +`; + +exports[`convert the range of dayOfWeek from Unix to AWS 1`] = ` +{ + "resource": { + "aws_cloudwatch_event_rule": { + "Schedule_15669BF1": { + "schedule_expression": "cron(* * ? * 0-6 *)", + }, + }, + "aws_cloudwatch_event_target": { + "Schedule_ScheduleTarget0_12D341DB": { + "arn": "\${aws_lambda_function.Schedule_OnTick0_958638E3.qualified_arn}", + "rule": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.name}", + }, + }, + "aws_cloudwatch_log_group": { + "Schedule_OnTick0_CloudwatchLogGroup_A06DC96E": { + "name": "/aws/lambda/OnTick0-c8e1d4a8", + "retention_in_days": 30, + }, + }, + "aws_iam_role": { + "Schedule_OnTick0_IamRole_478B0576": { + "assume_role_policy": "{"Version":"2012-10-17","Statement":[{"Action":"sts:AssumeRole","Principal":{"Service":"lambda.amazonaws.com"},"Effect":"Allow"}]}", + }, + }, + "aws_iam_role_policy": { + "Schedule_OnTick0_IamRolePolicy_708CFC38": { + "policy": "{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"none:null","Resource":"*"}]}", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_iam_role_policy_attachment": { + "Schedule_OnTick0_IamRolePolicyAttachment_5885D6B3": { + "policy_arn": "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.name}", + }, + }, + "aws_lambda_function": { + "Schedule_OnTick0_958638E3": { + "architectures": [ + "arm64", + ], + "environment": { + "variables": { + "NODE_OPTIONS": "--enable-source-maps", + "WING_FUNCTION_NAME": "OnTick0-c8e1d4a8", + }, + }, + "function_name": "OnTick0-c8e1d4a8", + "handler": "index.handler", + "memory_size": 1024, + "publish": true, + "role": "\${aws_iam_role.Schedule_OnTick0_IamRole_478B0576.arn}", + "runtime": "nodejs20.x", + "s3_bucket": "\${aws_s3_bucket.Code.bucket}", + "s3_key": "\${aws_s3_object.Schedule_OnTick0_S3Object_95D0AF10.key}", + "timeout": 60, + "vpc_config": { + "security_group_ids": [], + "subnet_ids": [], + }, + }, + }, + "aws_lambda_permission": { + "Schedule_OnTick0_InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3_17682171": { + "action": "lambda:InvokeFunction", + "function_name": "\${aws_lambda_function.Schedule_OnTick0_958638E3.function_name}", + "principal": "events.amazonaws.com", + "qualifier": "\${aws_lambda_function.Schedule_OnTick0_958638E3.version}", + "source_arn": "\${aws_cloudwatch_event_rule.Schedule_15669BF1.arn}", + }, + }, + "aws_s3_bucket": { + "Code": { + "bucket_prefix": "code-c84a50b1-", + }, + }, + "aws_s3_object": { + "Schedule_OnTick0_S3Object_95D0AF10": { + "bucket": "\${aws_s3_bucket.Code.bucket}", + "key": "", + "source": "", + }, + }, + }, +} +`; + +exports[`convert the range of dayOfWeek from Unix to AWS 2`] = ` +{ + "tree": { + "children": { + "root": { + "children": { + "Default": { + "children": { + "Code": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Code", + "path": "root/Default/Code", + }, + "ParameterRegistrar": { + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "ParameterRegistrar", + "path": "root/Default/ParameterRegistrar", + }, + "Schedule": { + "children": { + "OnTick0": { + "children": { + "Asset": { + "constructInfo": { + "fqn": "cdktf.TerraformAsset", + "version": "0.20.3", + }, + "id": "Asset", + "path": "root/Default/Schedule/OnTick0/Asset", + }, + "CloudwatchLogGroup": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "CloudwatchLogGroup", + "path": "root/Default/Schedule/OnTick0/CloudwatchLogGroup", + }, + "Default": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Default", + "path": "root/Default/Schedule/OnTick0/Default", + }, + "IamRole": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRole", + "path": "root/Default/Schedule/OnTick0/IamRole", + }, + "IamRolePolicy": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicy", + "path": "root/Default/Schedule/OnTick0/IamRolePolicy", + }, + "IamRolePolicyAttachment": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "IamRolePolicyAttachment", + "path": "root/Default/Schedule/OnTick0/IamRolePolicyAttachment", + }, + "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + "path": "root/Default/Schedule/OnTick0/InvokePermission-c8b3fc394731d07e61c00e422c6b234372c09bc3b3", + }, + "S3Object": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "S3Object", + "path": "root/Default/Schedule/OnTick0/S3Object", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud function (FaaS)", + "title": "Function", + }, + "id": "OnTick0", + "path": "root/Default/Schedule/OnTick0", + }, + "Schedule": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "Schedule", + "path": "root/Default/Schedule/Schedule", + }, + "ScheduleTarget0": { + "constructInfo": { + "fqn": "cdktf.TerraformResource", + "version": "0.20.3", + }, + "id": "ScheduleTarget0", + "path": "root/Default/Schedule/ScheduleTarget0", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "display": { + "description": "A cloud schedule to trigger events at regular intervals", + "title": "Schedule", + }, + "id": "Schedule", + "path": "root/Default/Schedule", + }, + "aws": { + "constructInfo": { + "fqn": "cdktf.TerraformProvider", + "version": "0.20.3", + }, + "id": "aws", + "path": "root/Default/aws", + }, + }, + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.3.0", + }, + "id": "Default", + "path": "root/Default", + }, + "backend": { + "constructInfo": { + "fqn": "cdktf.LocalBackend", + "version": "0.20.3", + }, + "id": "backend", + "path": "root/backend", + }, + }, + "constructInfo": { + "fqn": "cdktf.TerraformStack", + "version": "0.20.3", + }, + "id": "root", + "path": "root", + }, + }, + "constructInfo": { + "fqn": "cdktf.App", + "version": "0.20.3", + }, + "id": "App", + "path": "", + }, + "version": "tree-0.1", +} +`; + exports[`schedule behavior with cron 1`] = ` { "resource": { "aws_cloudwatch_event_rule": { "Schedule_15669BF1": { - "is_enabled": true, - "schedule_expression": "cron(0/1 * ? * * *)", + "schedule_expression": "cron(0/1 * * * ? *)", }, }, "aws_cloudwatch_event_target": { @@ -270,7 +1061,6 @@ exports[`schedule behavior with rate 1`] = ` "resource": { "aws_cloudwatch_event_rule": { "Schedule_15669BF1": { - "is_enabled": true, "schedule_expression": "rate(2 minutes)", }, }, @@ -535,8 +1325,7 @@ exports[`schedule with two functions 1`] = ` "resource": { "aws_cloudwatch_event_rule": { "Schedule_15669BF1": { - "is_enabled": true, - "schedule_expression": "cron(0/1 * ? * * *)", + "schedule_expression": "cron(0/1 * * * ? *)", }, }, "aws_cloudwatch_event_target": { diff --git a/libs/wingsdk/test/target-tf-aws/schedule.test.ts b/libs/wingsdk/test/target-tf-aws/schedule.test.ts index f3c53b36bb2..00e867c7534 100644 --- a/libs/wingsdk/test/target-tf-aws/schedule.test.ts +++ b/libs/wingsdk/test/target-tf-aws/schedule.test.ts @@ -49,7 +49,7 @@ test("schedule behavior with cron", () => { const app = new tfaws.App({ outdir: mkdtemp(), entrypointDir: __dirname }); const fn = Testing.makeHandler(CODE_LOG_EVENT); const schedule = new cloud.Schedule(app, "Schedule", { - cron: "0/1 * ? * *", + cron: "0/1 * * * *", }); schedule.onTick(fn); const output = app.synth(); @@ -72,7 +72,115 @@ test("schedule behavior with cron", () => { output, "aws_cloudwatch_event_rule", { - schedule_expression: "cron(0/1 * ? * * *)", + schedule_expression: "cron(0/1 * * * ? *)", + } + ) + ).toEqual(true); + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + +test("convert single dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new tfaws.App({ outdir: mkdtemp(), entrypointDir: __dirname }); + const fn = Testing.makeHandler(CODE_LOG_EVENT); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + expect(tfResourcesOf(output)).toEqual([ + "aws_cloudwatch_event_rule", // main schedule event + "aws_cloudwatch_event_target", // schedule target + "aws_cloudwatch_log_group", // log group for function + "aws_iam_role", // role for function + "aws_iam_role_policy", // policy for role + "aws_iam_role_policy_attachment", // execution policy for role + "aws_lambda_function", // processor function + "aws_lambda_permission", // function permission + "aws_s3_bucket", // S3 bucket for code + "aws_s3_object", // S3 object for code + ]); + expect( + cdktf.Testing.toHaveResourceWithProperties( + output, + "aws_cloudwatch_event_rule", + { + schedule_expression: "cron(* * ? * 0 *)", + } + ) + ).toEqual(true); + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + +test("convert the range of dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new tfaws.App({ outdir: mkdtemp(), entrypointDir: __dirname }); + const fn = Testing.makeHandler(CODE_LOG_EVENT); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1-7", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + expect(tfResourcesOf(output)).toEqual([ + "aws_cloudwatch_event_rule", // main schedule event + "aws_cloudwatch_event_target", // schedule target + "aws_cloudwatch_log_group", // log group for function + "aws_iam_role", // role for function + "aws_iam_role_policy", // policy for role + "aws_iam_role_policy_attachment", // execution policy for role + "aws_lambda_function", // processor function + "aws_lambda_permission", // function permission + "aws_s3_bucket", // S3 bucket for code + "aws_s3_object", // S3 object for code + ]); + expect( + cdktf.Testing.toHaveResourceWithProperties( + output, + "aws_cloudwatch_event_rule", + { + schedule_expression: "cron(* * ? * 0-6 *)", + } + ) + ).toEqual(true); + expect(tfSanitize(output)).toMatchSnapshot(); + expect(treeJsonOf(app.outdir)).toMatchSnapshot(); +}); + +test("convert the list of dayOfWeek from Unix to AWS", () => { + // GIVEN + const app = new tfaws.App({ outdir: mkdtemp(), entrypointDir: __dirname }); + const fn = Testing.makeHandler(CODE_LOG_EVENT); + const schedule = new cloud.Schedule(app, "Schedule", { + cron: "* * * * 1,3,5,7", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + expect(tfResourcesOf(output)).toEqual([ + "aws_cloudwatch_event_rule", // main schedule event + "aws_cloudwatch_event_target", // schedule target + "aws_cloudwatch_log_group", // log group for function + "aws_iam_role", // role for function + "aws_iam_role_policy", // policy for role + "aws_iam_role_policy_attachment", // execution policy for role + "aws_lambda_function", // processor function + "aws_lambda_permission", // function permission + "aws_s3_bucket", // S3 bucket for code + "aws_s3_object", // S3 object for code + ]); + expect( + cdktf.Testing.toHaveResourceWithProperties( + output, + "aws_cloudwatch_event_rule", + { + schedule_expression: "cron(* * ? * 0,2,4,6 *)", } ) ).toEqual(true); @@ -86,7 +194,7 @@ test("schedule with two functions", () => { const fn1 = Testing.makeHandler(CODE_LOG_EVENT); const fn2 = Testing.makeHandler(CODE_LOG_EVENT); const schedule = new cloud.Schedule(app, "Schedule", { - cron: "0/1 * ? * *", + cron: "0/1 * * * *", }); schedule.onTick(fn1); schedule.onTick(fn2); @@ -118,7 +226,7 @@ test("schedule with rate and cron simultaneously", () => { () => new cloud.Schedule(app, "Schedule", { rate: std.Duration.fromSeconds(30), - cron: "0/1 * ? * *", + cron: "0/1 * * * ?", }) ).toThrow("rate and cron cannot be configured simultaneously."); }); @@ -131,7 +239,7 @@ test("cron with more than five values", () => { expect( () => new cloud.Schedule(app, "Schedule", { - cron: "0/1 * ? * * *", + cron: "0/1 * * * * *", }) ).toThrow( "cron string must be UNIX cron format [minute] [hour] [day of month] [month] [day of week]" @@ -160,3 +268,18 @@ test("schedule with rate less than 1 minute", () => { }) ).toThrow("rate can not be set to less than 1 minute."); }); + +test("cron with day of month and day of week configured at the same time", () => { + // GIVEN + const app = new tfaws.App({ outdir: mkdtemp(), entrypointDir: __dirname }); + + // THEN + expect( + () => + new cloud.Schedule(app, "Schedule", { + cron: "* * 1 * 1", + }) + ).toThrow( + "Cannot restrict both 'day-of-month' and 'day-of-week' in a cron expression, at least one must be '*'" + ); +}); diff --git a/tools/hangar/__snapshots__/compatibility-spy.ts.snap b/tools/hangar/__snapshots__/compatibility-spy.ts.snap index 2911cd80b36..30af07fc9ba 100644 --- a/tools/hangar/__snapshots__/compatibility-spy.ts.snap +++ b/tools/hangar/__snapshots__/compatibility-spy.ts.snap @@ -494,6 +494,7 @@ exports[`initial.test.w 1`] = ` "args": { "methods": { "Counter": [ + "initial", "peek", ], "TestRunner": [ @@ -531,6 +532,7 @@ exports[`initial.test.w 1`] = ` "args": { "methods": { "Counter": [ + "initial", "peek", ], "TestRunner": [ @@ -568,6 +570,7 @@ exports[`initial.test.w 1`] = ` "args": { "methods": { "Counter": [ + "initial", "peek", ], "TestRunner": [ diff --git a/tools/hangar/__snapshots__/test_corpus/sdk_tests/schedule/on_tick.test.w_compile_tf-aws.md b/tools/hangar/__snapshots__/test_corpus/sdk_tests/schedule/on_tick.test.w_compile_tf-aws.md index ef9c211713d..c71573ca178 100644 --- a/tools/hangar/__snapshots__/test_corpus/sdk_tests/schedule/on_tick.test.w_compile_tf-aws.md +++ b/tools/hangar/__snapshots__/test_corpus/sdk_tests/schedule/on_tick.test.w_compile_tf-aws.md @@ -25,7 +25,6 @@ "uniqueId": "from_cron_Schedule_6C1613E8" } }, - "is_enabled": true, "schedule_expression": "cron(* * * * ? *)" }, "from_rate_Schedule_5B82E706": { @@ -35,7 +34,6 @@ "uniqueId": "from_rate_Schedule_5B82E706" } }, - "is_enabled": true, "schedule_expression": "rate(1 minute)" } }, diff --git a/tools/hangar/__snapshots__/test_corpus/valid/bring_local.test.w_compile_tf-aws.md b/tools/hangar/__snapshots__/test_corpus/valid/bring_local.test.w_compile_tf-aws.md index 31d9e298877..574fb7d8459 100644 --- a/tools/hangar/__snapshots__/test_corpus/valid/bring_local.test.w_compile_tf-aws.md +++ b/tools/hangar/__snapshots__/test_corpus/valid/bring_local.test.w_compile_tf-aws.md @@ -49,7 +49,7 @@ module.exports = function({ }) { constructor({ }) { } static async greet(name) { - return (require("../../../subdir/util.js")["greet"])(name) + return (require("../../../subdir/util.ts")["greet"])(name) } } return Q; diff --git a/tools/hangar/__snapshots__/test_corpus/valid/capture_tokens.test.w_compile_tf-aws.md b/tools/hangar/__snapshots__/test_corpus/valid/capture_tokens.test.w_compile_tf-aws.md index 4854c34282c..64085c279c8 100644 --- a/tools/hangar/__snapshots__/test_corpus/valid/capture_tokens.test.w_compile_tf-aws.md +++ b/tools/hangar/__snapshots__/test_corpus/valid/capture_tokens.test.w_compile_tf-aws.md @@ -52,7 +52,7 @@ module.exports = function({ }) { this.$this_url = $this_url; } static async isValidUrl(url) { - return (require("../../../url_utils.js")["isValidUrl"])(url) + return (require("../../../url_utils.ts")["isValidUrl"])(url) } async foo() { $helpers.assert((await MyResource.isValidUrl(this.$this_url)), "MyResource.isValidUrl(this.url)"); diff --git a/tools/hangar/__snapshots__/test_corpus/valid/extern_implementation.test.w_compile_tf-aws.md b/tools/hangar/__snapshots__/test_corpus/valid/extern_implementation.test.w_compile_tf-aws.md index 78c65ea6183..a6ecbf35cf2 100644 --- a/tools/hangar/__snapshots__/test_corpus/valid/extern_implementation.test.w_compile_tf-aws.md +++ b/tools/hangar/__snapshots__/test_corpus/valid/extern_implementation.test.w_compile_tf-aws.md @@ -87,6 +87,20 @@ module.exports = function({ }) { "aws": [ {} ] + }, + "resource": { + "aws_s3_bucket": { + "my-bucket": { + "//": { + "metadata": { + "path": "root/Default/Default/my-bucket/Default", + "uniqueId": "my-bucket" + } + }, + "bucket_prefix": "my-bucket-c8fafcc6-", + "force_destroy": false + } + } } } ``` @@ -111,6 +125,9 @@ class $Root extends $stdlib.std.Resource { static getGreeting(name) { return (require("../../../external_js.js")["getGreeting"])(name) } + static preflightBucket(bucket, id) { + return (require("../../../external_js.js")["preflightBucket"])(bucket, id) + } static _toInflightType() { return ` require("${$helpers.normalPath(__dirname)}/inflight.Foo-1.js")({ @@ -223,6 +240,8 @@ class $Root extends $stdlib.std.Resource { } $helpers.assert($helpers.eq((Foo.getGreeting("Wingding")), "Hello, Wingding!"), "Foo.getGreeting(\"Wingding\") == \"Hello, Wingding!\""); const f = new Foo(this, "Foo"); + const bucket = this.node.root.new("@winglang/sdk.cloud.Bucket", cloud.Bucket, this, "my-bucket"); + const result = (Foo.preflightBucket(bucket, "my-bucket")); this.node.root.new("@winglang/sdk.std.Test", std.Test, this, "test:call", new $Closure1(this, "$Closure1")); this.node.root.new("@winglang/sdk.std.Test", std.Test, this, "test:console", new $Closure2(this, "$Closure2")); }